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}