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}