use crate::pricing::Plan;
use crate::session::{CtxPoint, Session, SessionData, Subagent, Tokens};
use crate::util;
use serde::Serialize;
use std::collections::HashMap;
const MAX_FAILURE_CLUSTERS: usize = 12;
const MAX_FAILURE_SAMPLES: usize = 3;
const MAX_TOOLS: usize = 20;
const MAX_SLOWEST: usize = 10;
const HUMAN_WAIT_TOOLS: &[&str] = &[
"AskUserQuestion",
"ask_user_question",
"ExitPlanMode",
"exit_plan_mode",
];
const MAX_HEAVIEST: usize = 12;
const MAX_FILES: usize = 40;
const MAX_DIFF_FILES: usize = 40;
const MAX_HUNKS_PER_FILE: usize = 300;
#[derive(Serialize)]
pub struct Report {
pub session_id: String,
pub provider: &'static str,
pub title: Option<String>,
pub project: Option<String>,
pub branch: Option<String>,
pub profile: Option<String>,
pub model: Option<String>,
pub models: Vec<String>,
pub started_at: String,
pub last_active: String,
pub duration: String,
pub running: bool,
pub state: &'static str,
pub plan: &'static str,
pub error: Option<String>,
pub cost: ReportCost,
pub tokens: Tokens,
pub context: Option<ReportContext>,
pub activity: ReportActivity,
pub files: Vec<String>,
pub diffs: Vec<ReportFileDiff>,
pub subagents: Vec<Subagent>,
}
#[derive(Serialize)]
pub struct ReportFileDiff {
pub file: String,
pub added: u32,
pub removed: u32,
pub edits: usize,
pub hunks: Vec<String>,
pub truncated: bool,
}
#[derive(Serialize)]
pub struct ReportCost {
pub available: bool,
pub included: bool,
pub total: f64,
pub by_model: Vec<ReportModelCost>,
pub by_day: Vec<(String, f64)>,
pub by_hour: Vec<(String, f64)>,
}
#[derive(Serialize)]
pub struct ReportModelCost {
pub model: String,
pub total: f64,
pub tokens: Tokens,
}
#[derive(Serialize)]
pub struct ReportContext {
pub used: u64,
pub max: u64,
pub percent_to_compact: f64,
pub compactions: u32,
pub breakdown: Option<ReportBreakdown>,
pub series: Vec<CtxPoint>,
}
#[derive(Serialize)]
pub struct ReportBreakdown {
pub total: u64,
pub startup: u64,
pub tool_output: u64,
pub tool_input: u64,
pub attachments: u64,
pub user_text: u64,
pub assistant_text: u64,
pub unaccounted: i64,
pub after_compaction: bool,
pub superseded: bool,
}
#[derive(Serialize)]
pub struct ReportActivity {
pub tool_count: u64,
pub tool_errors: Option<u64>,
pub error_rate: Option<f64>,
pub lines_added: u64,
pub lines_removed: u64,
pub tools: Vec<ReportTool>,
pub failures: Vec<ReportFailure>,
pub slowest: Vec<ReportCall>,
pub heaviest: Vec<ReportCall>,
pub calls: Vec<ReportCall>,
}
#[derive(Serialize)]
pub struct ReportTool {
pub name: String,
pub calls: u64,
pub failed: u64,
}
#[derive(Serialize)]
pub struct ReportFailure {
pub tool: String,
pub detail: String,
pub count: u64,
pub samples: Vec<ReportCall>,
}
#[derive(Serialize)]
pub struct ReportCall {
pub tool: String,
pub detail: String,
pub ts: String,
pub duration_ms: Option<i64>,
pub failed: bool,
pub window_growth: Option<u64>,
pub shared: u8,
pub origin: Option<String>,
}
pub fn build(session: &Session, data: &SessionData, plan: Plan) -> Report {
let included = session.cost_available && plan.includes(session.provider);
let metrics = &data.metrics;
Report {
session_id: session.session_id.clone(),
provider: session.provider.as_str(),
title: data
.custom_title
.clone()
.or_else(|| data.title.clone())
.or_else(|| session.title.clone()),
project: (!session.label_source.is_empty()).then(|| util::tildify(&session.label_source)),
branch: crate::ui::columns::branch_of(session),
profile: session.profile.clone(),
model: (!session.model.is_empty()).then(|| session.model.clone()),
models: data.models.clone(),
started_at: session.started_at.clone(),
last_active: session.last_active.clone(),
duration: util::session_duration(&session.started_at, &session.last_active),
running: session.is_running(),
state: match session.activity_state {
crate::session::ActivityState::Working => "working",
crate::session::ActivityState::WaitingForInput => "waiting",
crate::session::ActivityState::ApiError => "error",
},
plan: plan.as_str(),
error: data.error.clone(),
cost: ReportCost {
available: session.cost_available,
included,
total: data.costs.total,
by_model: data
.model_breakdown
.iter()
.map(|m| ReportModelCost {
model: m.model.clone(),
total: m.total,
tokens: m.tokens.clone(),
})
.collect(),
by_day: sorted_buckets(&data.costs_by_day),
by_hour: sorted_buckets(&data.costs_by_hour),
},
tokens: data.tokens.clone(),
context: session.context.map(|usage| ReportContext {
used: usage.used,
max: usage.max,
percent_to_compact: usage.percent_to_compact(),
compactions: data.compactions,
breakdown: data.context_breakdown.as_ref().map(|b| ReportBreakdown {
total: b.total,
startup: b.startup,
tool_output: b.tool_output,
tool_input: b.tool_input,
attachments: b.attachments,
user_text: b.user_text,
assistant_text: b.assistant_text,
unaccounted: b.unaccounted(),
after_compaction: b.after_compaction,
superseded: b.superseded,
}),
series: data.context_series.clone(),
}),
activity: ReportActivity {
tool_count: metrics.tool_count,
tool_errors: session
.provider
.records_tool_outcomes()
.then_some(metrics.tool_errors),
error_rate: session.error_rate(),
lines_added: metrics.lines_added,
lines_removed: metrics.lines_removed,
tools: tools(data),
failures: failures(data),
slowest: slowest(data),
heaviest: heaviest(data),
calls: calls(data),
},
files: util::abbreviate_paths(&data.recent_writes)
.into_iter()
.take(MAX_FILES)
.collect(),
diffs: diffs(data),
subagents: data.subagents.clone(),
}
}
fn sorted_buckets(buckets: &HashMap<String, HashMap<String, f64>>) -> Vec<(String, f64)> {
let mut out: Vec<(String, f64)> = buckets
.iter()
.map(|(k, models)| (k.clone(), models.values().sum()))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn tools(data: &SessionData) -> Vec<ReportTool> {
let mut out: Vec<ReportTool> = data
.metrics
.tools
.iter()
.map(|(name, &calls)| ReportTool {
name: name.clone(),
calls,
failed: data
.metrics
.tool_details
.get(name)
.map_or(0, |calls| calls.iter().filter(|c| c.failed).count() as u64),
})
.collect();
out.sort_by(|a, b| b.calls.cmp(&a.calls).then(a.name.cmp(&b.name)));
out.truncate(MAX_TOOLS);
out
}
fn failures(data: &SessionData) -> Vec<ReportFailure> {
let mut clusters: HashMap<(&str, &str), Vec<ReportCall>> = HashMap::new();
for (tool, calls) in &data.metrics.tool_details {
for call in calls.iter().filter(|c| c.failed) {
let key = call.full.as_deref().unwrap_or(&call.d);
clusters
.entry((tool.as_str(), key))
.or_default()
.push(ReportCall {
tool: tool.clone(),
detail: call.d.clone(),
ts: call.ts.clone(),
duration_ms: call.dur_ms,
failed: true,
window_growth: call.window_growth,
shared: call.shared,
origin: call.origin.clone(),
});
}
}
let mut out: Vec<ReportFailure> = clusters
.into_iter()
.map(|((tool, detail), mut samples)| {
let count = samples.len() as u64;
samples.sort_by(|a, b| a.ts.cmp(&b.ts));
samples.truncate(MAX_FAILURE_SAMPLES);
ReportFailure {
tool: tool.to_string(),
detail: util::truncate(detail, 200),
count,
samples,
}
})
.collect();
out.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then(a.tool.cmp(&b.tool))
.then(a.detail.cmp(&b.detail))
});
out.truncate(MAX_FAILURE_CLUSTERS);
out
}
fn heaviest(data: &SessionData) -> Vec<ReportCall> {
let mut out: Vec<ReportCall> = calls(data)
.into_iter()
.filter(|c| c.window_growth.is_some_and(|g| g > 0))
.collect();
out.sort_by_key(|c| std::cmp::Reverse(c.window_growth));
out.truncate(MAX_HEAVIEST);
out
}
fn calls(data: &SessionData) -> Vec<ReportCall> {
let mut out: Vec<ReportCall> = data
.metrics
.tool_details
.iter()
.flat_map(|(tool, calls)| {
calls.iter().map(move |call| ReportCall {
tool: tool.clone(),
detail: call.d.clone(),
ts: call.ts.clone(),
duration_ms: call.dur_ms,
failed: call.failed,
window_growth: call.window_growth,
shared: call.shared,
origin: call.origin.clone(),
})
})
.collect();
out.sort_by(|a, b| b.ts.cmp(&a.ts));
out
}
#[derive(Default)]
struct FileEdits<'a> {
added: u32,
removed: u32,
edits: usize,
parts: Vec<(&'a str, &'a Vec<String>)>,
}
fn diffs(data: &SessionData) -> Vec<ReportFileDiff> {
let mut by_file: HashMap<&str, FileEdits<'_>> = HashMap::new();
for calls in data.metrics.tool_details.values() {
for call in calls {
let Some(delta) = &call.delta else { continue };
if delta.hunks.is_empty() {
continue;
}
let entry = by_file.entry(call.d.as_str()).or_default();
entry.added += delta.added;
entry.removed += delta.removed;
entry.edits += 1;
entry.parts.push((call.ts.as_str(), &delta.hunks));
}
}
let paths: Vec<String> = by_file.keys().map(|f| (*f).to_string()).collect();
let short: HashMap<String, String> = paths
.iter()
.cloned()
.zip(util::abbreviate_paths(&paths))
.collect();
let mut out: Vec<ReportFileDiff> = by_file
.into_iter()
.map(|(file, mut edits)| {
edits.parts.sort_by_key(|(ts, _)| *ts);
let mut hunks: Vec<String> = Vec::new();
let mut truncated = false;
for (_, lines) in &edits.parts {
for line in lines.iter() {
if hunks.len() >= MAX_HUNKS_PER_FILE {
truncated = true;
break;
}
hunks.push(line.clone());
}
}
ReportFileDiff {
file: short.get(file).cloned().unwrap_or_else(|| file.to_string()),
added: edits.added,
removed: edits.removed,
edits: edits.edits,
hunks,
truncated,
}
})
.collect();
out.sort_by(|a, b| {
(b.added + b.removed)
.cmp(&(a.added + a.removed))
.then(a.file.cmp(&b.file))
});
out.truncate(MAX_DIFF_FILES);
out
}
fn slowest(data: &SessionData) -> Vec<ReportCall> {
let mut out: Vec<ReportCall> = data
.metrics
.tool_details
.iter()
.filter(|(tool, _)| !HUMAN_WAIT_TOOLS.contains(&tool.as_str()))
.flat_map(|(tool, calls)| {
calls.iter().filter_map(move |call| {
call.dur_ms.map(|ms| ReportCall {
tool: tool.clone(),
detail: call.d.clone(),
ts: call.ts.clone(),
duration_ms: Some(ms),
failed: call.failed,
window_growth: call.window_growth,
shared: call.shared,
origin: call.origin.clone(),
})
})
})
.collect();
out.sort_by_key(|call| std::cmp::Reverse(call.duration_ms));
out.truncate(MAX_SLOWEST);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{Metrics, ToolDetail};
fn failed_call(detail: &str, ts: &str) -> ToolDetail {
ToolDetail {
d: detail.to_string(),
ts: ts.to_string(),
failed: true,
..Default::default()
}
}
fn data_with(details: HashMap<String, Vec<ToolDetail>>) -> SessionData {
SessionData {
metrics: Metrics {
tool_details: details,
..Default::default()
},
..Default::default()
}
}
#[test]
fn identical_failures_collapse_into_one_counted_cluster() {
let data = data_with(HashMap::from([(
"Bash".to_string(),
vec![
failed_call("cargo buidl", "2026-08-19T10:00:00Z"),
failed_call("cargo buidl", "2026-08-19T10:01:00Z"),
failed_call("cargo buidl", "2026-08-19T10:02:00Z"),
failed_call("ls /nope", "2026-08-19T10:03:00Z"),
],
)]));
let found = failures(&data);
assert_eq!(found.len(), 2, "two distinct arguments failed");
assert_eq!(found[0].detail, "cargo buidl");
assert_eq!(found[0].count, 3);
assert_eq!(found[1].count, 1);
}
#[test]
fn the_same_argument_to_different_tools_stays_separate() {
let data = data_with(HashMap::from([
("Bash".to_string(), vec![failed_call("x", "1")]),
("Read".to_string(), vec![failed_call("x", "2")]),
]));
assert_eq!(failures(&data).len(), 2);
}
#[test]
fn clustering_uses_the_full_argument_where_the_display_form_was_cut() {
let mut long_a = failed_call("git log --oneline …", "1");
long_a.full = Some("git log --oneline --since=yesterday -- src/a.rs".into());
let mut long_b = failed_call("git log --oneline …", "2");
long_b.full = Some("git log --oneline --since=yesterday -- src/b.rs".into());
let data = data_with(HashMap::from([("Bash".to_string(), vec![long_a, long_b])]));
assert_eq!(failures(&data).len(), 2);
}
#[test]
fn successful_calls_are_not_failures() {
let data = data_with(HashMap::from([(
"Bash".to_string(),
vec![ToolDetail {
d: "cargo test".into(),
ts: "1".into(),
..Default::default()
}],
)]));
assert!(failures(&data).is_empty());
}
#[test]
fn slowest_ranks_by_wall_time_and_ignores_untimed_calls() {
let timed = |ms: Option<i64>| ToolDetail {
d: format!("{ms:?}"),
dur_ms: ms,
..Default::default()
};
let data = data_with(HashMap::from([(
"Bash".to_string(),
vec![timed(Some(10)), timed(None), timed(Some(9_000))],
)]));
let ranked = slowest(&data);
assert_eq!(ranked.len(), 2, "the untimed call cannot be ranked");
assert_eq!(ranked[0].duration_ms, Some(9_000));
}
#[test]
fn edits_to_one_file_become_one_diff_in_the_order_they_landed() {
use crate::session::Delta;
let edit = |file: &str, ts: &str, added, removed, hunk: &str| ToolDetail {
d: file.to_string(),
ts: ts.to_string(),
delta: Some(Delta {
added,
removed,
hunks: vec![hunk.to_string()],
}),
..Default::default()
};
let data = data_with(HashMap::from([(
"Edit".to_string(),
vec![
edit("b.rs", "2026-08-19T10:00:00Z", 1, 0, "+one"),
edit("a.rs", "2026-08-19T10:02:00Z", 5, 2, "+second"),
edit("a.rs", "2026-08-19T10:01:00Z", 4, 1, "+first"),
],
)]));
let found = diffs(&data);
assert_eq!(found.len(), 2);
assert!(found.iter().all(|f| f.file.ends_with(".rs")));
assert_eq!(found[0].file, "a.rs");
assert_eq!((found[0].added, found[0].removed), (9, 3));
assert_eq!(found[0].edits, 2);
assert_eq!(found[0].hunks, vec!["+first", "+second"]);
assert!(!found[0].truncated);
}
#[test]
fn diff_file_names_are_abbreviated_against_each_other() {
use crate::session::Delta;
let edit = |file: &str| ToolDetail {
d: file.to_string(),
ts: "2026-08-19T10:00:00Z".into(),
delta: Some(Delta {
added: 1,
removed: 0,
hunks: vec!["+x".into()],
}),
..Default::default()
};
let data = data_with(HashMap::from([(
"Edit".to_string(),
vec![
edit("/home/flo/cctop/.claude/worktrees/agent-9/src/ui/theme.rs"),
edit("/home/flo/cctop/.claude/worktrees/agent-9/src/ui/table.rs"),
],
)]));
for f in diffs(&data) {
assert!(
!f.file.starts_with("/home/flo"),
"the shared prefix survived: {}",
f.file
);
assert!(f.file.ends_with(".rs"), "{}", f.file);
}
}
#[test]
fn a_call_with_no_patch_contributes_no_diff() {
let data = data_with(HashMap::from([(
"Bash".to_string(),
vec![ToolDetail {
d: "ls".into(),
..Default::default()
}],
)]));
assert!(diffs(&data).is_empty());
}
#[test]
fn the_call_log_is_newest_first_across_every_tool() {
let at = |tool: &str, ts: &str| (tool.to_string(), ts.to_string());
let (bash, read) = (
at("Bash", "2026-08-19T10:00:00Z"),
at("Read", "2026-08-19T10:05:00Z"),
);
let data = data_with(HashMap::from([
(
bash.0,
vec![ToolDetail {
d: "ls".into(),
ts: bash.1,
..Default::default()
}],
),
(
read.0,
vec![ToolDetail {
d: "a.rs".into(),
ts: read.1,
..Default::default()
}],
),
]));
let log = calls(&data);
assert_eq!(log.len(), 2);
assert_eq!(log[0].tool, "Read");
assert_eq!(log[1].tool, "Bash");
}
#[test]
fn the_call_log_keeps_the_subagent_that_made_a_call() {
let data = data_with(HashMap::from([(
"Grep".to_string(),
vec![
ToolDetail {
d: "fn main".into(),
ts: "2026-08-19T10:00:00Z".into(),
origin: Some("Explore".into()),
..Default::default()
},
ToolDetail {
d: "fn build".into(),
ts: "2026-08-19T10:01:00Z".into(),
..Default::default()
},
],
)]));
let log = calls(&data);
assert_eq!(log[0].origin, None, "the session's own call names no agent");
assert_eq!(log[1].origin.as_deref(), Some("Explore"));
}
#[test]
fn the_window_ranking_is_by_what_a_result_added_not_by_when_it_ran() {
let call = |detail: &str, ts: &str, held: u64, grew: Option<u64>| ToolDetail {
d: detail.to_string(),
ts: ts.to_string(),
tokens_in: held,
window_growth: grew,
..Default::default()
};
let data = data_with(HashMap::from([(
"Read".to_string(),
vec![
call("huge.rs", "2026-08-19T10:00:00Z", 12_000, Some(40_000)),
call("tiny.rs", "2026-08-19T11:00:00Z", 190_000, Some(60)),
call("unweighed.rs", "2026-08-19T11:01:00Z", 191_000, None),
],
)]));
let ranked = heaviest(&data);
assert_eq!(ranked.len(), 2, "a call with no measurement is not ranked");
assert_eq!(ranked[0].detail, "huge.rs");
assert_eq!(ranked[0].window_growth, Some(40_000));
assert_eq!(ranked[1].detail, "tiny.rs");
}
#[test]
fn waiting_on_a_person_is_not_a_slow_call() {
let timed = |ms: i64| ToolDetail {
d: "x".into(),
dur_ms: Some(ms),
..Default::default()
};
let data = data_with(HashMap::from([
("AskUserQuestion".to_string(), vec![timed(90_000)]),
("ExitPlanMode".to_string(), vec![timed(80_000)]),
("Bash".to_string(), vec![timed(4_000)]),
]));
let ranked = slowest(&data);
assert_eq!(ranked.len(), 1, "only the agent's own work is ranked");
assert_eq!(ranked[0].tool, "Bash");
assert_eq!(calls(&data).len(), 3);
}
#[test]
fn cost_buckets_come_back_oldest_first() {
let buckets = HashMap::from([
("2026-08-19".to_string(), HashMap::from([("m".into(), 2.0)])),
("2026-08-17".to_string(), HashMap::from([("m".into(), 1.0)])),
]);
let sorted = sorted_buckets(&buckets);
assert_eq!(sorted[0].0, "2026-08-17");
assert_eq!(sorted[1].0, "2026-08-19");
}
}