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` (`message.rs:63`):
17//! the two are 1:1 by `tool_call_id` (the loop synthesizes an error ToolResult
18//! for every unexecuted/blocked call — `loop_.rs:683`), so they count the same
19//! calls, but the Assistant block is the canonical, dedup-free definition and
20//! does not depend on the loop's pairing invariant holding.
21
22use std::collections::HashMap;
23
24use crate::message::{AgentMessage, ContentBlock};
25
26/// How many tool names the rendered `tools:` segment lists before `+K more`.
27/// Many tools exist (`crates/wcode-cli/src/tools/`); the list is a glance, not
28/// a manifest.
29pub const TOOL_LIST_CAP: usize = 5;
30
31/// Aggregate view of a conversation for `/usage`: how many turns reported
32/// usage, the summed token counts, and which tools ran.
33///
34/// Pure data — no timing, no cost (there is no timestamp in `AgentMessage`).
35/// Derive order is fixed so tests can compare by value.
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct SessionStats {
38    /// Assistant messages that carried `Some(usage)`.
39    pub turns: u64,
40    pub input_tokens: u64,
41    pub output_tokens: u64,
42    /// Cache counts absent on a call are treated as 0.
43    pub cache_read_tokens: u64,
44    pub cache_write_tokens: u64,
45    /// Tool name → call count, one entry per distinct name, sorted by count
46    /// **descending**, ties broken by name **ascending**. Empty when no tool
47    /// has been called.
48    pub tools: Vec<(String, u64)>,
49    /// Provider-reported input tokens of the most recent Assistant message with
50    /// `Some(usage)`: how full the context was on the last request. `None`
51    /// until a turn reports usage.
52    pub last_input_tokens: Option<u64>,
53}
54
55/// Fold a conversation into a [`SessionStats`].
56///
57/// Contract:
58/// - `turns`/token sums iterate every `AgentMessage::Assistant { usage: Some(u), .. }`;
59///   a missing `cache_read_tokens`/`cache_write_tokens` counts as 0.
60/// - `tools` tallies every `ContentBlock::ToolCall { name, .. }` across
61///   Assistant messages (independently of whether a matching ToolResult
62///   exists), then sorts by count desc, name asc.
63/// - `last_input_tokens` is the `input_tokens` of the last Assistant with
64///   `Some(usage)`.
65/// - Empty input ⇒ `SessionStats::default()`.
66pub fn session_stats(messages: &[AgentMessage]) -> SessionStats {
67    let mut stats = SessionStats::default();
68    let mut tally: HashMap<&str, u64> = HashMap::new();
69    for m in messages {
70        let AgentMessage::Assistant { content, usage, .. } = m else {
71            continue;
72        };
73        if let Some(u) = usage {
74            stats.turns += 1;
75            stats.input_tokens += u.input_tokens;
76            stats.output_tokens += u.output_tokens;
77            stats.cache_read_tokens += u.cache_read_tokens.unwrap_or(0);
78            stats.cache_write_tokens += u.cache_write_tokens.unwrap_or(0);
79            stats.last_input_tokens = Some(u.input_tokens);
80        }
81        for block in content {
82            if let ContentBlock::ToolCall { name, .. } = block {
83                *tally.entry(name.as_str()).or_insert(0) += 1;
84            }
85        }
86    }
87    let mut tools: Vec<(String, u64)> =
88        tally.into_iter().map(|(n, c)| (n.to_string(), c)).collect();
89    tools.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
90    stats.tools = tools;
91    stats
92}
93
94impl SessionStats {
95    /// The one-line summary both front-ends print: plain text — no colors, no
96    /// layout, no context; front-ends add chrome.
97    ///
98    /// Contract:
99    /// - `turns == 0` ⇒ `"(no usage reported)"`.
100    /// - otherwise ⇒
101    ///   `"usage: N turns, X in, Y out[, C cache read][, W cache write][, tools: …]"`
102    ///   where the cache fields are omitted when 0, the `tools:` segment is
103    ///   omitted when no tool ran, and the tool names are capped at
104    ///   [`TOOL_LIST_CAP`], the remainder folded into `"+K more"`.
105    /// - The trailing `context U/L` readout is intentionally NOT here: its
106    ///   limit comes from the front-end (REPL `model_limit`, TUI
107    ///   `status.context_limit`), so each wraps/appends it.
108    pub fn summary(&self) -> String {
109        if self.turns == 0 {
110            return "(no usage reported)".to_string();
111        }
112        let mut parts = vec![
113            format!(
114                "{} turn{}",
115                self.turns,
116                if self.turns == 1 { "" } else { "s" }
117            ),
118            format!("{} in", self.input_tokens),
119            format!("{} out", self.output_tokens),
120        ];
121        if self.cache_read_tokens > 0 {
122            parts.push(format!("{} cache read", self.cache_read_tokens));
123        }
124        if self.cache_write_tokens > 0 {
125            parts.push(format!("{} cache write", self.cache_write_tokens));
126        }
127        if let Some(tools) = self.tools_segment() {
128            parts.push(format!("tools: {tools}"));
129        }
130        format!("usage: {}", parts.join(", "))
131    }
132
133    /// The `read×12 grep×4 … +K more` segment, or `None` when no tool ran.
134    /// Factored out so a front-end can place the tool list independently if it
135    /// ever wants to; `summary()` uses it for the inline form.
136    fn tools_segment(&self) -> Option<String> {
137        if self.tools.is_empty() {
138            return None;
139        }
140        let shown = self.tools.len().min(TOOL_LIST_CAP);
141        let mut seg = self.tools[..shown]
142            .iter()
143            .map(|(name, count)| format!("{name}×{count}"))
144            .collect::<Vec<_>>()
145            .join(" ");
146        if self.tools.len() > TOOL_LIST_CAP {
147            seg.push_str(&format!(" +{} more", self.tools.len() - TOOL_LIST_CAP));
148        }
149        Some(seg)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::message::{StopReason, Usage};
157    use serde_json::json;
158
159    // --- helpers (mirror the ones deleted from repl.rs `assistant_with_usage`) ---
160
161    fn assistant_usage(input: u64, output: u64, read: Option<u64>, write: Option<u64>) -> AgentMessage {
162        AgentMessage::Assistant {
163            content: vec![ContentBlock::Text { text: "a".into() }],
164            stop_reason: StopReason::Stop,
165            usage: Some(Usage {
166                input_tokens: input,
167                output_tokens: output,
168                cache_read_tokens: read,
169                cache_write_tokens: write,
170            }),
171            model: None,
172        }
173    }
174
175    fn assistant_tools(names: &[&str]) -> AgentMessage {
176        AgentMessage::Assistant {
177            content: names
178                .iter()
179                .enumerate()
180                .map(|(i, n)| ContentBlock::ToolCall {
181                    id: format!("t{i}"),
182                    name: (*n).into(),
183                    arguments: json!({}),
184                })
185                .collect(),
186            stop_reason: StopReason::ToolUse,
187            usage: None,
188            model: None,
189        }
190    }
191
192    #[test]
193    fn empty_is_default() {
194        assert_eq!(session_stats(&[]), SessionStats::default());
195    }
196
197    #[test]
198    fn counts_usage_only_on_reported_turns() {
199        // Two assistants with usage, one without, plus a user message → turns 2,
200        // summed tokens, cache absent == 0.
201        let messages = vec![
202            AgentMessage::user_text("q"),
203            assistant_usage(10, 20, Some(3), None),
204            AgentMessage::Assistant {
205                content: vec![ContentBlock::Text { text: "x".into() }],
206                stop_reason: StopReason::Stop,
207                usage: None,
208                model: None,
209            },
210            assistant_usage(30, 40, None, Some(5)),
211        ];
212        assert_eq!(
213            session_stats(&messages),
214            SessionStats {
215                turns: 2,
216                input_tokens: 40,
217                output_tokens: 60,
218                cache_read_tokens: 3,
219                cache_write_tokens: 5,
220                tools: vec![],
221                last_input_tokens: Some(30),
222            }
223        );
224    }
225
226    #[test]
227    fn tallies_tool_calls_by_name() {
228        // assistant_tools(&["read","read","grep"]) → [("read",2),("grep",1)].
229        let stats = session_stats(&[assistant_tools(&["read", "read", "grep"])]);
230        assert_eq!(
231            stats.tools,
232            vec![("read".to_string(), 2), ("grep".to_string(), 1)]
233        );
234        // Tool calls are counted with or without usage on the message.
235        assert_eq!(stats.turns, 0);
236    }
237
238    #[test]
239    fn tools_sorted_count_desc_then_name_asc() {
240        // ["zzz","aaa","aaa","bbb"] → [("aaa",2),("bbb",1),("zzz",1)]:
241        // count desc, then name asc on the tied count-1 entries.
242        let stats = session_stats(&[assistant_tools(&["zzz", "aaa", "aaa", "bbb"])]);
243        assert_eq!(
244            stats.tools,
245            vec![
246                ("aaa".to_string(), 2),
247                ("bbb".to_string(), 1),
248                ("zzz".to_string(), 1),
249            ]
250        );
251    }
252
253    #[test]
254    fn last_input_tokens_is_most_recent() {
255        // [usage(10,..), usage(40,..)] → Some(40); [] → None.
256        let messages = vec![
257            assistant_usage(10, 20, None, None),
258            AgentMessage::user_text("q"),
259            assistant_usage(40, 3, None, None),
260            assistant_tools(&["read"]), // no usage: skipped
261        ];
262        assert_eq!(session_stats(&messages).last_input_tokens, Some(40));
263        assert_eq!(session_stats(&[]).last_input_tokens, None);
264        assert_eq!(
265            session_stats(&[AgentMessage::user_text("q")]).last_input_tokens,
266            None
267        );
268    }
269
270    #[test]
271    fn summary_empty_and_full_line() {
272        // default → "(no usage reported)".
273        assert_eq!(SessionStats::default().summary(), "(no usage reported)");
274
275        // turns 2, 40 in, 60 out, read 3, write 5, tools read×1 →
276        // "usage: 2 turns, 40 in, 60 out, 3 cache read, 5 cache write, tools: read×1".
277        let messages = vec![
278            assistant_usage(40, 60, Some(3), Some(5)),
279            assistant_tools(&["read"]),
280            AgentMessage::user_text("q"),
281            assistant_usage(0, 0, None, None),
282        ];
283        assert_eq!(
284            session_stats(&messages).summary(),
285            "usage: 2 turns, 40 in, 60 out, 3 cache read, 5 cache write, tools: read×1"
286        );
287
288        // Singular "1 turn".
289        let one = SessionStats {
290            turns: 1,
291            input_tokens: 10,
292            output_tokens: 20,
293            ..Default::default()
294        };
295        assert_eq!(one.summary(), "usage: 1 turn, 10 in, 20 out");
296    }
297
298    #[test]
299    fn summary_caps_tools_and_says_more() {
300        // 7 distinct tools → 5 names then "+2 more".
301        let stats = SessionStats {
302            turns: 1,
303            input_tokens: 1,
304            output_tokens: 1,
305            tools: vec![
306                ("a".to_string(), 1),
307                ("b".to_string(), 1),
308                ("c".to_string(), 1),
309                ("d".to_string(), 1),
310                ("e".to_string(), 1),
311                ("f".to_string(), 1),
312                ("g".to_string(), 1),
313            ],
314            last_input_tokens: Some(1),
315            ..Default::default()
316        };
317        assert_eq!(
318            stats.summary(),
319            "usage: 1 turn, 1 in, 1 out, tools: a×1 b×1 c×1 d×1 e×1 +2 more"
320        );
321    }
322
323    #[test]
324    fn summary_omits_empty_tools_segment() {
325        // usage reported, no tool calls → no ", tools:" suffix.
326        let stats = SessionStats {
327            turns: 1,
328            input_tokens: 10,
329            output_tokens: 20,
330            ..Default::default()
331        };
332        let line = stats.summary();
333        assert_eq!(line, "usage: 1 turn, 10 in, 20 out");
334        assert!(!line.contains("tools:"));
335    }
336}