Skip to main content

wcode_harness/
stats.rs

1//! Session summary for `/usage`.
2//!
3//! One shared implementation of the aggregate view both front-ends print: the
4//! REPL's text line and the TUI's notice. Keeping it in the kernel means the
5//! tally and its wording live in one place; each front-end adds only its own
6//! surrounding chrome.
7//!
8//! Why a new module rather than folding into `message.rs`: `message.rs` is the
9//! serde wire model (`ContentBlock` at `message.rs:5`, `AgentMessage` at
10//! `message.rs:47`); `stats` is *analysis over* that model. Keeping the model
11//! pure means the derived view can change (add fields, change the cap) without
12//! touching the wire format or its round-trip tests.
13//!
14//! Source of the tool tally: `AgentMessage::Assistant` `ContentBlock::ToolCall`
15//! blocks (`message.rs:11`), i.e. the model's *intent*, exactly once per call.
16//! We deliberately do NOT use the paired `ToolResult.name`
17//! (`message.rs:63`) for the CALL tally: the two are 1:1 by
18//! `tool_call_id` (the loop synthesizes an error ToolResult for every
19//! unexecuted/blocked call — `loop_.rs:688`, `synthesize_unexecuted`
20//! at `loop_.rs:798`), so they count the same calls, but the Assistant
21//! block is the canonical, dedup-free definition.
22//!
23//! The ERROR tally DOES depend on that pairing, and says so: `errors`
24//! and `result_bytes` are attributed by `tool_call_id` — never by the
25//! result's own `name`, which is the loop's copy — so a call whose
26//! result the transcript lost (a compacted prefix, a mid-run snapshot)
27//! contributes its call but no error, and a result with no call
28//! contributes nothing at all.
29
30use std::collections::HashMap;
31
32use serde::{Deserialize, Serialize};
33
34use crate::message::{AgentMessage, ContentBlock};
35
36/// One tool's tally: how many times it was called, how many of those calls did
37/// not succeed, and how many bytes its results fed the context.
38///
39/// `errors` counts every `ToolResult { is_error: true }` paired to this tool's
40/// `ToolCall` — INCLUDING the loop's synthesized aborts for blocked/unexecuted
41/// calls (`loop_.rs:650` `output: "aborted before execution"`,
42/// `fn synthesize_unexecuted` at `loop_.rs:798`). So "error" here means *the
43/// call did not succeed*, not *the tool is buggy*; v1 does not separate
44/// "blocked by a hook" from "failed" (that would need a new transcript flag).
45///
46/// `result_bytes` = Σ `ToolResult.output.len()` — the honest stand-in for a
47/// per-tool token count, which nothing records: `ToolOutput` has no token field
48/// (`tool.rs:23` `pub struct ToolOutput {` … fields end at `tool.rs:35`
49/// `pub path: Option<String>,`) and usage is per TURN, provider-billed
50/// (`message.rs:21`). It answers "which tool is eating my window?" without
51/// inventing a number.
52#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ToolStat {
54    pub name: String,
55    pub calls: u64,
56    pub errors: u64,
57    pub result_bytes: u64,
58}
59
60/// A partial cost: what we COULD price, and how many turns we could not.
61/// Micro-USD (1e-6 USD), integers only — so it stays `Eq` and serde-stable, and
62/// the `$` a front-end prints is reproducible.
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct Cost {
65    pub micro_usd: u64,
66    /// Turns that reported usage but had no known price (an unknown model, or a
67    /// mid-run switch to one). `summary()` names them: a total that hid them
68    /// would misreport money.
69    pub unpriced_turns: u64,
70}
71
72/// How many tool names the rendered `tools:` segment lists before `+K more`.
73/// Many tools exist (`crates/wcode-cli/src/tools/`); the list is a glance, not
74/// a manifest.
75pub const TOOL_LIST_CAP: usize = 5;
76
77/// Aggregate view of a conversation for `/usage`: how many turns reported
78/// usage, the summed token counts, and which tools ran.
79///
80/// Pure data — no timing, and no cost OF ITS OWN: `cost` is filled by
81/// the caller that knows the provider (`pricing::cost_of`, called from
82/// the actor's `Request::Usage` reply), because pricing keys on a
83/// `base_url`/`model` pair that an `AgentMessage` cannot carry. Every
84/// other field folds from the transcript alone.
85/// Derive order is fixed so tests can compare by value.
86#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SessionStats {
88    /// Assistant messages that carried `Some(usage)`.
89    pub turns: u64,
90    pub input_tokens: u64,
91    pub output_tokens: u64,
92    /// Cache counts absent on a call are treated as 0.
93    pub cache_read_tokens: u64,
94    pub cache_write_tokens: u64,
95    /// Assistant messages that reported AT LEAST ONE cache field — the honest
96    /// denominator for `cache_hit_pct()`. A provider that reports nothing must
97    /// OMIT the rate rather than print `0%` (`streamfn.rs:1255`
98    /// `cache_read_tokens: (usage.cached_input_tokens > 0).then_some(...)`:
99    /// zero is rig's sentinel for "not reported").
100    pub cache_turns: u64,
101    /// One entry per distinct tool name, sorted by `calls` **descending**, ties
102    /// by name **ascending**. Empty when no tool has been called. (WAS
103    /// `Vec<(String, u64)>` — same sort, more facts.)
104    pub tools: Vec<ToolStat>,
105    /// Σ `tools[..].errors` — the headline count `summary()` prints.
106    pub tool_errors: u64,
107    /// Provider-reported input tokens of the most recent Assistant message with
108    /// `Some(usage)`. `None` until a turn reports usage. NOT summed by
109    /// `aggregate()` (see below): it is a "how full was the last request" fact.
110    pub last_input_tokens: Option<u64>,
111    /// The session's `$` cost. `None` from the pure fold (the fold has no
112    /// provider); `Some` once the actor prices it. `skip_serializing_if` keeps
113    /// it off the wire when absent — mandatory for an additive variant.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub cost: Option<Cost>,
116}
117
118/// Fold a conversation into a [`SessionStats`].
119///
120/// Contract:
121/// - `turns`/token sums iterate every `AgentMessage::Assistant { usage: Some(u), .. }`;
122///   a missing `cache_read_tokens`/`cache_write_tokens` counts as 0.
123/// - (+) `cache_turns` counts those same messages when EITHER cache field is
124///   `Some` — the rate's denominator, so an unreported cache is omitted, never
125///   `0%`.
126/// - `tools` tallies every `ContentBlock::ToolCall { name, .. }` across
127///   Assistant messages (independently of whether a matching ToolResult
128///   exists), then sorts by `calls` desc, name asc.
129/// - (+) a first pass maps `ContentBlock::ToolCall { id, name, .. }` →
130///   `(name, tally index)`; a second walk over `AgentMessage::ToolResult
131///   { tool_call_id, is_error, output, .. }` bumps `errors` (when `is_error`)
132///   and adds `output.len()` to `result_bytes` on THAT call's row. The result's
133///   own `name` is never used for attribution — the id is the pairing
134///   (`message.rs:59` `tool_call_id: String,`).
135/// - (+) a `ToolCall` with NO matching `ToolResult` is `calls += 1,
136///   errors += 0`: a mid-run snapshot can legitimately catch a call in flight,
137///   and a lost result is not a failure. Counting it as an error would invent
138///   one.
139/// - (+) a `ToolResult` whose `tool_call_id` matches no `ToolCall` bumps
140///   nothing: there is no name to attribute it to (this is what a compacted
141///   prefix looks like). The old doc's "does not depend on the pairing
142///   invariant" claim is thereby AMENDED, not weakened — it still holds for
143///   `calls`, and no longer for `errors`/`result_bytes`.
144/// - (+) `tool_errors` = Σ errors over the tallies.
145/// - (+) `cost` is left `None`: pricing needs the provider's `base_url`/`model`,
146///   which no `AgentMessage` carries. The caller that knows it (the actor's
147///   `Request::Usage` arm) sets `cost` with `pricing::cost_of(..)` on the SAME
148///   slice, so `turns == priced + unpriced` there.
149/// - `last_input_tokens` is the `input_tokens` of the last Assistant with
150///   `Some(usage)`.
151/// - (+) `is_error` includes the loop's synthesized aborts
152///   (`loop_.rs:650`/`loop_.rs:798`), so `errors` means "the call did not
153///   succeed", not "the tool failed" — say it in the doc, and in D028.
154/// - Empty input ⇒ `SessionStats::default()`.
155pub fn session_stats(messages: &[AgentMessage]) -> SessionStats {
156    let mut stats = SessionStats::default();
157    // name -> index into `stats.tools`
158    let mut index: HashMap<String, usize> = HashMap::new();
159    // tool_call_id -> the tool's name (the error/byte attribution key)
160    let mut call_name: HashMap<&str, &str> = HashMap::new();
161
162    // Pass 1: usage + the canonical call tally.
163    for m in messages {
164        let AgentMessage::Assistant { content, usage, .. } = m else {
165            continue;
166        };
167        if let Some(u) = usage {
168            stats.turns += 1;
169            stats.input_tokens += u.input_tokens;
170            stats.output_tokens += u.output_tokens;
171            stats.cache_read_tokens += u.cache_read_tokens.unwrap_or(0);
172            stats.cache_write_tokens += u.cache_write_tokens.unwrap_or(0);
173            if u.cache_read_tokens.is_some() || u.cache_write_tokens.is_some() {
174                stats.cache_turns += 1;
175            }
176            stats.last_input_tokens = Some(u.input_tokens);
177        }
178        for block in content {
179            if let ContentBlock::ToolCall { id, name, .. } = block {
180                call_name.insert(id.as_str(), name.as_str());
181                let i = match index.get(name) {
182                    Some(&i) => i,
183                    None => {
184                        stats.tools.push(ToolStat {
185                            name: name.clone(),
186                            calls: 0,
187                            errors: 0,
188                            result_bytes: 0,
189                        });
190                        let i = stats.tools.len() - 1;
191                        index.insert(name.clone(), i);
192                        i
193                    }
194                };
195                stats.tools[i].calls += 1;
196            }
197        }
198    }
199
200    // Pass 2: attribute each ToolResult to its call's row, by id.
201    for m in messages {
202        let AgentMessage::ToolResult {
203            tool_call_id,
204            output,
205            is_error,
206            ..
207        } = m
208        else {
209            continue;
210        };
211        let Some(name) = call_name.get(tool_call_id.as_str()) else {
212            continue;
213        };
214        if let Some(&i) = index.get(*name) {
215            if *is_error {
216                stats.tools[i].errors += 1;
217            }
218            stats.tools[i].result_bytes += output.len() as u64;
219        }
220    }
221
222    stats.tool_errors = stats.tools.iter().map(|t| t.errors).sum();
223    stats
224        .tools
225        .sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.name.cmp(&b.name)));
226    stats
227}
228
229/// Sum per-session views into a total (the `/usage` `total` row).
230///
231/// Contract:
232/// - `turns`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
233///   `cache_write_tokens`, `cache_turns`, `tool_errors`: plain sums.
234/// - `tools`: merged BY NAME — `calls`/`errors`/`result_bytes` summed per name —
235///   then re-sorted `calls` desc, name asc, so the total reads like a single
236///   session.
237/// - `cost`: `Some(Cost { micro_usd: Σ, unpriced_turns: Σ })` when AT LEAST ONE
238///   member has `Some`; `None` when none does. A member with `cost: None`
239///   contributes nothing — its sessions are unknown, not "0 unpriced turns".
240/// - `last_input_tokens`: **always `None`**. A total has no "last request"; the
241///   context gauge is a per-surface fact and must never be summed.
242/// - empty slice ⇒ `SessionStats::default()`.
243///
244/// Does NOT promise: deduplication. Two members that share a transcript (a
245/// peer and its mirror) are counted twice — a caller passes each session once.
246pub fn aggregate(members: &[SessionStats]) -> SessionStats {
247    let mut out = SessionStats::default();
248    let mut index: HashMap<String, usize> = HashMap::new();
249    for m in members {
250        out.turns += m.turns;
251        out.input_tokens += m.input_tokens;
252        out.output_tokens += m.output_tokens;
253        out.cache_read_tokens += m.cache_read_tokens;
254        out.cache_write_tokens += m.cache_write_tokens;
255        out.cache_turns += m.cache_turns;
256        out.tool_errors += m.tool_errors;
257        for t in &m.tools {
258            let i = match index.get(&t.name) {
259                Some(&i) => i,
260                None => {
261                    out.tools.push(ToolStat {
262                        name: t.name.clone(),
263                        calls: 0,
264                        errors: 0,
265                        result_bytes: 0,
266                    });
267                    let i = out.tools.len() - 1;
268                    index.insert(t.name.clone(), i);
269                    i
270                }
271            };
272            out.tools[i].calls += t.calls;
273            out.tools[i].errors += t.errors;
274            out.tools[i].result_bytes += t.result_bytes;
275        }
276    }
277    let micro_usd = members
278        .iter()
279        .filter_map(|m| m.cost.as_ref())
280        .map(|c| c.micro_usd)
281        .sum();
282    let unpriced_turns = members
283        .iter()
284        .map(|m| match &m.cost {
285            Some(c) => c.unpriced_turns,
286            None => m.turns, // not one turn priced ⇒ every reported turn is unpriced
287        })
288        .sum();
289    out.cost = members
290        .iter()
291        .any(|m| m.cost.is_some())
292        .then_some(Cost {
293            micro_usd,
294            unpriced_turns,
295        });
296    // `last_input_tokens` stays `None`: a total has no "last request".
297    out.tools
298        .sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.name.cmp(&b.name)));
299    out
300}
301
302impl SessionStats {
303    /// The one-line summary both front-ends print: plain text — no colors, no
304    /// layout, no context; front-ends add chrome.
305    ///
306    /// Contract:
307    /// - `turns == 0` ⇒ `"(no usage reported)"`.
308    /// - otherwise ⇒
309    ///   `"usage: N turns, X in, Y out[, C cache read][, W cache write][, tools: …]"`
310    ///   where the cache fields are omitted when 0, the `tools:` segment is
311    ///   omitted when no tool ran, and the tool names are capped at
312    ///   [`TOOL_LIST_CAP`], the remainder folded into `"+K more"`.
313    /// - appends `, N% cache` when `cache_hit_pct()` is `Some`,
314    ///   `, N tool error(s)` when `tool_errors > 0`, and `, ~$d.dddd`
315    ///   when `cost` is `Some` — `(N turns unpriced)` when
316    ///   `Cost::unpriced_turns > 0`. `% cache` sits right after the cache
317    ///   tokens, `tool error(s)` after `tools:`, and `~$` last.
318    /// - The trailing `context U/L` readout is intentionally NOT here: its
319    ///   limit comes from the front-end (REPL `model_limit`, TUI
320    ///   `status.context_limit`), so each wraps/appends it.
321    pub fn summary(&self) -> String {
322        if self.turns == 0 {
323            return "(no usage reported)".to_string();
324        }
325        let mut parts = vec![
326            format!(
327                "{} turn{}",
328                self.turns,
329                if self.turns == 1 { "" } else { "s" }
330            ),
331            format!("{} in", self.input_tokens),
332            format!("{} out", self.output_tokens),
333        ];
334        if self.cache_read_tokens > 0 {
335            parts.push(format!("{} cache read", self.cache_read_tokens));
336        }
337        if self.cache_write_tokens > 0 {
338            parts.push(format!("{} cache write", self.cache_write_tokens));
339        }
340        if let Some(pct) = self.cache_hit_pct() {
341            parts.push(format!("{pct}% cache"));
342        }
343        if let Some(tools) = self.tools_segment() {
344            parts.push(format!("tools: {tools}"));
345        }
346        if self.tool_errors > 0 {
347            parts.push(format!(
348                "{} tool error{}",
349                self.tool_errors,
350                if self.tool_errors == 1 { "" } else { "s" }
351            ));
352        }
353        if let Some(cost) = &self.cost {
354            parts.push(if cost.unpriced_turns == 0 {
355                format!("~{}", Self::format_usd(cost.micro_usd))
356            } else {
357                format!(
358                    "~{} ({} turn{} unpriced)",
359                    Self::format_usd(cost.micro_usd),
360                    cost.unpriced_turns,
361                    if cost.unpriced_turns == 1 { "" } else { "s" }
362                )
363            });
364        }
365        format!("usage: {}", parts.join(", "))
366    }
367
368    /// Cache hit rate as an integer percent, rounded to the nearest percent.
369    ///
370    /// Contract:
371    /// - `Some((cache_read * 100 + input / 2) / input)` when `input_tokens > 0
372    ///   && cache_turns > 0`, else `None`: a session that reported no cache
373    ///   OMITS the clause instead of claiming `0%`.
374    /// - the denominator is `input_tokens` ALONE. On the chat-completions wire a
375    ///   cached read is a SUBSET of `input_tokens` (rig maps
376    ///   `prompt_tokens_details.cached_tokens`), so adding `cache_read` back
377    ///   would double-count. A provider on the Anthropic wire — where
378    ///   `input_tokens` EXCLUDES cache reads/writes — would understate here;
379    ///   wcode speaks the OpenAI-compatible wire only, and that is the
380    ///   documented assumption (D028).
381    /// - saturates at 100: more cached tokens than input is a provider bug, and
382    ///   `120%` on screen would be worse than the clamp.
383    /// - `cache_write` is not a hit: it is reported separately and priced.
384    pub fn cache_hit_pct(&self) -> Option<u8> {
385        if self.input_tokens == 0 || self.cache_turns == 0 {
386            return None;
387        }
388        let pct =
389            (self.cache_read_tokens * 100 + self.input_tokens / 2) / self.input_tokens;
390        Some(pct.min(100) as u8)
391    }
392
393    /// Share of tool calls that did not succeed, rounded to the nearest percent.
394    ///
395    /// `Some((tool_errors * 100 + calls / 2) / calls)` over Σ`calls` when a tool
396    /// ran at all, else `None`. `errors` counts hook-blocked and synthesized
397    /// aborts (see `ToolStat`), so this is "not a success", never "the tool is
398    /// buggy"; saturates at 100 for the same reason as `cache_hit_pct`.
399    pub fn tool_error_pct(&self) -> Option<u8> {
400        let calls: u64 = self.tools.iter().map(|t| t.calls).sum();
401        if calls == 0 {
402            return None;
403        }
404        let pct = (self.tool_errors * 100 + calls / 2) / calls;
405        Some(pct.min(100) as u8)
406    }
407
408    /// `$0.0123` — dollars, four decimals, TRUNCATED at the last digit so the
409    /// printed value never overstates the micro-USD it came from (brief §7.2:
410    /// "the printed `~$` must agree to the truncated micro-USD").
411    ///
412    /// A priced-but-tiny cost prints `$0.0000`; the `~` `summary()` prefixes
413    /// marks it approximate. Private: only `summary()` renders money.
414    fn format_usd(micro_usd: u64) -> String {
415        format!(
416            "${}.{:04}",
417            micro_usd / 1_000_000,
418            (micro_usd % 1_000_000) / 100
419        )
420    }
421
422    /// The `read×12 grep×4 … +K more` segment, or `None` when no tool ran.
423    /// Factored out so a front-end can place the tool list independently if it
424    /// ever wants to; `summary()` uses it for the inline form.
425    fn tools_segment(&self) -> Option<String> {
426        if self.tools.is_empty() {
427            return None;
428        }
429        let shown = self.tools.len().min(TOOL_LIST_CAP);
430        let mut seg = self.tools[..shown]
431            .iter()
432            .map(|t| format!("{}×{}", t.name, t.calls))
433            .collect::<Vec<_>>()
434            .join(" ");
435        if self.tools.len() > TOOL_LIST_CAP {
436            seg.push_str(&format!(" +{} more", self.tools.len() - TOOL_LIST_CAP));
437        }
438        Some(seg)
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::message::{StopReason, Usage};
446    use serde_json::json;
447
448    // --- helpers (mirror the ones deleted from repl.rs `assistant_with_usage`) ---
449
450    fn assistant_usage(input: u64, output: u64, read: Option<u64>, write: Option<u64>) -> AgentMessage {
451        AgentMessage::Assistant {
452            content: vec![ContentBlock::Text { text: "a".into() }],
453            stop_reason: StopReason::Stop,
454            usage: Some(Usage {
455                input_tokens: input,
456                output_tokens: output,
457                cache_read_tokens: read,
458                cache_write_tokens: write,
459            }),
460            model: None,
461        }
462    }
463
464    fn assistant_tools(names: &[&str]) -> AgentMessage {
465        AgentMessage::Assistant {
466            content: names
467                .iter()
468                .enumerate()
469                .map(|(i, n)| ContentBlock::ToolCall {
470                    id: format!("t{i}"),
471                    name: (*n).into(),
472                    arguments: json!({}),
473                })
474                .collect(),
475            stop_reason: StopReason::ToolUse,
476            usage: None,
477            model: None,
478        }
479    }
480
481    fn result(id: &str, name: &str, output: &str, is_error: bool) -> AgentMessage {
482        AgentMessage::ToolResult {
483            tool_call_id: id.into(),
484            name: name.into(),
485            output: output.into(),
486            is_error,
487        }
488    }
489
490    fn stat(name: &str, calls: u64, errors: u64, bytes: u64) -> ToolStat {
491        ToolStat {
492            name: name.into(),
493            calls,
494            errors,
495            result_bytes: bytes,
496        }
497    }
498
499    #[test]
500    fn empty_is_default() {
501        assert_eq!(session_stats(&[]), SessionStats::default());
502    }
503
504    #[test]
505    fn counts_usage_only_on_reported_turns() {
506        // Two assistants with usage, one without, plus a user message → turns 2,
507        // summed tokens, cache absent == 0.
508        let messages = vec![
509            AgentMessage::user_text("q"),
510            assistant_usage(10, 20, Some(3), None),
511            AgentMessage::Assistant {
512                content: vec![ContentBlock::Text { text: "x".into() }],
513                stop_reason: StopReason::Stop,
514                usage: None,
515                model: None,
516            },
517            assistant_usage(30, 40, None, Some(5)),
518        ];
519        assert_eq!(
520            session_stats(&messages),
521            SessionStats {
522                turns: 2,
523                input_tokens: 40,
524                output_tokens: 60,
525                cache_read_tokens: 3,
526                cache_write_tokens: 5,
527                cache_turns: 2,
528                tools: vec![],
529                tool_errors: 0,
530                last_input_tokens: Some(30),
531                cost: None,
532            }
533        );
534    }
535
536    #[test]
537    fn tallies_tool_calls_by_name() {
538        // assistant_tools(&["read","read","grep"]) → [read×2, grep×1].
539        let stats = session_stats(&[assistant_tools(&["read", "read", "grep"])]);
540        assert_eq!(stats.tools, vec![stat("read", 2, 0, 0), stat("grep", 1, 0, 0)]);
541        // Tool calls are counted with or without usage on the message.
542        assert_eq!(stats.turns, 0);
543    }
544
545    #[test]
546    fn tools_sorted_count_desc_then_name_asc() {
547        // ["zzz","aaa","aaa","bbb"] → [aaa×2, bbb×1, zzz×1]:
548        // count desc, then name asc on the tied count-1 entries.
549        let stats = session_stats(&[assistant_tools(&["zzz", "aaa", "aaa", "bbb"])]);
550        assert_eq!(
551            stats.tools,
552            vec![stat("aaa", 2, 0, 0), stat("bbb", 1, 0, 0), stat("zzz", 1, 0, 0)]
553        );
554    }
555
556    #[test]
557    fn last_input_tokens_is_most_recent() {
558        // [usage(10,..), usage(40,..)] → Some(40); [] → None.
559        let messages = vec![
560            assistant_usage(10, 20, None, None),
561            AgentMessage::user_text("q"),
562            assistant_usage(40, 3, None, None),
563            assistant_tools(&["read"]), // no usage: skipped
564        ];
565        assert_eq!(session_stats(&messages).last_input_tokens, Some(40));
566        assert_eq!(session_stats(&[]).last_input_tokens, None);
567        assert_eq!(
568            session_stats(&[AgentMessage::user_text("q")]).last_input_tokens,
569            None
570        );
571    }
572
573    #[test]
574    fn summary_empty_and_full_line() {
575        // default → "(no usage reported)".
576        assert_eq!(SessionStats::default().summary(), "(no usage reported)");
577
578        // turns 2, 40 in, 60 out, read 3, write 5, tools read×1 →
579        // "usage: 2 turns, 40 in, 60 out, 3 cache read, 5 cache write, 8% cache, tools: read×1".
580        let messages = vec![
581            assistant_usage(40, 60, Some(3), Some(5)),
582            assistant_tools(&["read"]),
583            AgentMessage::user_text("q"),
584            assistant_usage(0, 0, None, None),
585        ];
586        assert_eq!(
587            session_stats(&messages).summary(),
588            "usage: 2 turns, 40 in, 60 out, 3 cache read, 5 cache write, 8% cache, tools: read×1"
589        );
590
591        // Singular "1 turn".
592        let one = SessionStats {
593            turns: 1,
594            input_tokens: 10,
595            output_tokens: 20,
596            ..Default::default()
597        };
598        assert_eq!(one.summary(), "usage: 1 turn, 10 in, 20 out");
599    }
600
601    #[test]
602    fn summary_caps_tools_and_says_more() {
603        // 7 distinct tools → 5 names then "+2 more".
604        let stats = SessionStats {
605            turns: 1,
606            input_tokens: 1,
607            output_tokens: 1,
608            tools: vec![
609                stat("a", 1, 0, 0),
610                stat("b", 1, 0, 0),
611                stat("c", 1, 0, 0),
612                stat("d", 1, 0, 0),
613                stat("e", 1, 0, 0),
614                stat("f", 1, 0, 0),
615                stat("g", 1, 0, 0),
616            ],
617            last_input_tokens: Some(1),
618            ..Default::default()
619        };
620        assert_eq!(
621            stats.summary(),
622            "usage: 1 turn, 1 in, 1 out, tools: a×1 b×1 c×1 d×1 e×1 +2 more"
623        );
624    }
625
626    #[test]
627    fn summary_omits_empty_tools_segment() {
628        // usage reported, no tool calls → no ", tools:" suffix.
629        let stats = SessionStats {
630            turns: 1,
631            input_tokens: 10,
632            output_tokens: 20,
633            ..Default::default()
634        };
635        let line = stats.summary();
636        assert_eq!(line, "usage: 1 turn, 10 in, 20 out");
637        assert!(!line.contains("tools:"));
638    }
639
640    /// One call, one `is_error: true` result ⇒ calls 1, errors 1, bytes =
641    /// `output.len()`. The pairing is the ID, not the result's `name`: give the
642    /// result a DIFFERENT name and assert it still lands on the call's row.
643    #[test]
644    fn tool_errors_pair_by_call_id() {
645        let messages = vec![
646            assistant_tools(&["read"]), // ToolCall id "t0", name "read"
647            result("t0", "grep", "boom", true), // name differs; id is the key
648        ];
649        let stats = session_stats(&messages);
650        assert_eq!(stats.tools, vec![stat("read", 1, 1, 4)]);
651        assert_eq!(stats.tool_errors, 1);
652    }
653
654    /// A call with no result (a mid-run snapshot) ⇒ calls 1, errors 0,
655    /// result_bytes 0 — a call in flight is not a failure.
656    #[test]
657    fn a_call_without_a_result_counts_as_a_call_not_an_error() {
658        let stats = session_stats(&[assistant_tools(&["read"])]);
659        assert_eq!(stats.tools, vec![stat("read", 1, 0, 0)]);
660        assert_eq!(stats.tool_errors, 0);
661    }
662
663    /// The loop's synthesized `"aborted before execution"` result
664    /// (`loop_.rs:650`, `synthesize_unexecuted` at `loop_.rs:798`) carries
665    /// `is_error: true` ⇒ it DOES count: "not a success", not "tool bug".
666    #[test]
667    fn a_synthesized_abort_result_is_counted_as_an_error() {
668        let messages = vec![
669            assistant_tools(&["read"]),
670            result("t0", "read", "aborted before execution", true),
671        ];
672        let stats = session_stats(&messages);
673        assert_eq!(stats.tools[0].errors, 1);
674        assert_eq!(stats.tool_errors, 1);
675    }
676
677    /// `result_bytes` is the per-name Σ of `output.len()` (bytes, not chars, not
678    /// tokens), and a result with an unknown id adds nothing.
679    #[test]
680    fn result_bytes_sum_per_tool() {
681        let messages = vec![
682            assistant_tools(&["read", "read"]), // t0, t1
683            result("t0", "read", "abc", false),  // 3 bytes
684            result("t1", "read", "de", false),   // 2 bytes
685            result("gone", "read", "ignored", false), // unknown id: nothing
686        ];
687        let stats = session_stats(&messages);
688        assert_eq!(stats.tools, vec![stat("read", 2, 0, 5)]);
689    }
690
691    /// `cache_read`/`cache_write` both absent on every turn ⇒ `cache_turns == 0`
692    /// ⇒ `cache_hit_pct() == None` ⇒ no `% cache` clause (never `0%`).
693    #[test]
694    fn cache_hit_pct_is_none_without_cache_reporting() {
695        let stats = session_stats(&[assistant_usage(100, 10, None, None)]);
696        assert_eq!(stats.cache_turns, 0);
697        assert_eq!(stats.cache_hit_pct(), None);
698        assert!(!stats.summary().contains("% cache"));
699    }
700
701    /// Rounding: read 1 of input 3 ⇒ 33%; read 2 of 3 ⇒ 67%; read 3 of 3 ⇒ 100%
702    /// (and a hypothetical read > input saturates, never 120%).
703    #[test]
704    fn cache_hit_pct_rounds_to_nearest_percent() {
705        assert_eq!(
706            session_stats(&[assistant_usage(3, 0, Some(1), None)]).cache_hit_pct(),
707            Some(33)
708        );
709        assert_eq!(
710            session_stats(&[assistant_usage(3, 0, Some(2), None)]).cache_hit_pct(),
711            Some(67)
712        );
713        assert_eq!(
714            session_stats(&[assistant_usage(3, 0, Some(3), None)]).cache_hit_pct(),
715            Some(100)
716        );
717        assert_eq!(
718            session_stats(&[assistant_usage(3, 0, Some(6), None)]).cache_hit_pct(),
719            Some(100)
720        );
721    }
722
723    /// No tool ran ⇒ `tool_error_pct() == None` (not 0%), for the same reason.
724    #[test]
725    fn tool_error_pct_is_none_with_no_calls() {
726        let stats = session_stats(&[assistant_usage(1, 1, None, None)]);
727        assert_eq!(stats.tool_error_pct(), None);
728        assert!(!stats.summary().contains("tool error"));
729    }
730
731    /// Two members ⇒ scalar sums, tools merged by name and re-sorted, `cost`
732    /// summed when one side has `Some`, and `last_input_tokens == None`.
733    #[test]
734    fn aggregate_sums_two_members_and_sorts_tools() {
735        let a = SessionStats {
736            turns: 1,
737            input_tokens: 10,
738            output_tokens: 20,
739            cache_turns: 1,
740            tools: vec![stat("read", 2, 0, 5)],
741            cost: Some(Cost {
742                micro_usd: 4_000,
743                unpriced_turns: 0,
744            }),
745            last_input_tokens: Some(10),
746            ..Default::default()
747        };
748        let b = SessionStats {
749            turns: 2,
750            input_tokens: 30,
751            output_tokens: 40,
752            // `tool_errors` is a plain sum (contract), so a hand-built member
753            // carries the headline its tools imply (as `session_stats` would).
754            tools: vec![stat("read", 1, 1, 7), stat("grep", 3, 0, 2)],
755            tool_errors: 1,
756            ..Default::default()
757        };
758        let total = aggregate(&[a, b]);
759        assert_eq!(total.turns, 3);
760        assert_eq!(total.input_tokens, 40);
761        assert_eq!(total.output_tokens, 60);
762        assert_eq!(total.cache_turns, 1);
763        assert_eq!(total.tool_errors, 1);
764        // read 2+1 = 3, grep 3 → tie on count, name asc puts grep first.
765        assert_eq!(
766            total.tools,
767            vec![stat("grep", 3, 0, 2), stat("read", 3, 1, 12)]
768        );
769        // `a` priced, `b` unpriced ⇒ micro summed, and b's 2 turns counted unpriced.
770        assert_eq!(
771            total.cost,
772            Some(Cost {
773                micro_usd: 4_000,
774                unpriced_turns: 2,
775            })
776        );
777        assert_eq!(total.last_input_tokens, None);
778    }
779
780    /// `cost: Some(Cost { micro_usd: 4_000, unpriced_turns: 0 })` ⇒ the line
781    /// ends `~$0.0040`; `unpriced_turns: 1` ⇒ `~$0.0040 (1 turn unpriced)`.
782    #[test]
783    fn summary_reports_cost_and_unpriced_turns() {
784        let priced = SessionStats {
785            turns: 1,
786            input_tokens: 1,
787            output_tokens: 1,
788            cost: Some(Cost {
789                micro_usd: 4_000,
790                unpriced_turns: 0,
791            }),
792            ..Default::default()
793        };
794        assert!(
795            priced.summary().ends_with("~$0.0040"),
796            "{}",
797            priced.summary()
798        );
799
800        let partial = SessionStats {
801            turns: 1,
802            input_tokens: 1,
803            output_tokens: 1,
804            cost: Some(Cost {
805                micro_usd: 4_000,
806                unpriced_turns: 1,
807            }),
808            ..Default::default()
809        };
810        assert!(
811            partial.summary().ends_with("~$0.0040 (1 turn unpriced)"),
812            "{}",
813            partial.summary()
814        );
815    }
816}