use std::collections::{BTreeMap, BTreeSet, HashMap};
use jiff::civil::Date;
use serde::Serialize;
use crate::model::{EventKind, Session, Usage};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ContextSource {
UserPrompts,
AssistantText,
Thinking,
ToolCalls,
ToolResults,
Attachments,
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceBreakdown {
pub source: ContextSource,
pub chars: u64,
pub est_tokens: u64,
pub share: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServerBreakdown {
pub server: String,
pub calls: u64,
pub call_chars: u64,
pub result_chars: u64,
pub est_tokens: u64,
}
impl ServerBreakdown {
pub const BUILTIN: &'static str = "(built-in)";
}
#[derive(Debug, Clone, Serialize)]
pub struct HeavyItem {
pub source: ContextSource,
pub label: Option<String>,
pub session_id: String,
pub chars: u64,
pub est_tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionContext {
pub id: String,
pub project: Option<String>,
pub model: Option<String>,
pub sidechain: bool,
pub startup_overhead_tokens: Option<u64>,
pub cold_start: bool,
pub peak_input_tokens: Option<u64>,
pub final_input_tokens: Option<u64>,
pub sources: Vec<SourceBreakdown>,
pub total_content_chars: u64,
pub compactions: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ContextReport {
pub agent: String,
pub since: Option<Date>,
pub sessions_profiled: u64,
pub sessions: Vec<SessionContext>,
pub by_source: Vec<SourceBreakdown>,
pub by_server: Vec<ServerBreakdown>,
pub heaviest: Vec<HeavyItem>,
pub mcp_servers: Vec<String>,
pub deferred_tools: u64,
pub skills_listed: u64,
pub mcp_instruction_chars: u64,
pub attachment_chars: u64,
pub compactions: u64,
pub max_startup_overhead: Option<(String, u64)>,
}
const HEAVIEST_N: usize = 12;
fn in_window(since: Option<Date>, event: &crate::model::Event) -> bool {
match (since, event.ts) {
(None, _) => true,
(Some(since), Some(ts)) => super::utc_date(ts) >= since,
(Some(_), None) => false,
}
}
fn ctx_tokens(u: &Usage) -> u64 {
u.input.unwrap_or(0) + u.cache_read.unwrap_or(0) + u.cache_creation.unwrap_or(0)
}
#[derive(Default)]
struct SourceTally {
user_prompts: u64,
assistant_text: u64,
thinking: u64,
tool_calls: u64,
tool_results: u64,
attachments: u64,
}
impl SourceTally {
fn total(&self) -> u64 {
self.user_prompts
+ self.assistant_text
+ self.thinking
+ self.tool_calls
+ self.tool_results
+ self.attachments
}
fn breakdown(&self) -> Vec<SourceBreakdown> {
let total = self.total();
let mut rows: Vec<SourceBreakdown> = [
(ContextSource::UserPrompts, self.user_prompts),
(ContextSource::AssistantText, self.assistant_text),
(ContextSource::Thinking, self.thinking),
(ContextSource::ToolCalls, self.tool_calls),
(ContextSource::ToolResults, self.tool_results),
(ContextSource::Attachments, self.attachments),
]
.into_iter()
.filter(|(_, chars)| *chars > 0)
.map(|(source, chars)| SourceBreakdown {
source,
chars,
est_tokens: super::est_tokens(chars),
share: if total == 0 {
0.0
} else {
chars as f64 / total as f64
},
})
.collect();
rows.sort_by(|a, b| {
b.chars
.cmp(&a.chars)
.then_with(|| source_rank(a.source).cmp(&source_rank(b.source)))
});
rows
}
}
fn source_rank(s: ContextSource) -> u8 {
match s {
ContextSource::UserPrompts => 0,
ContextSource::AssistantText => 1,
ContextSource::Thinking => 2,
ContextSource::ToolCalls => 3,
ContextSource::ToolResults => 4,
ContextSource::Attachments => 5,
}
}
fn tool_call_bytes(event: &crate::model::Event) -> u64 {
event.tool_calls.iter().map(|c| c.input_bytes).sum()
}
pub fn profile(sessions: &[Session], agent: &str, since: Option<Date>) -> ContextReport {
let refs: Vec<&Session> = sessions.iter().collect();
profile_refs(&refs, agent, since)
}
pub fn profile_refs(sessions: &[&Session], agent: &str, since: Option<Date>) -> ContextReport {
let mut session_rows: Vec<SessionContext> = Vec::new();
let mut global_sources = SourceTally::default();
let mut heaviest: Vec<HeavyItem> = Vec::new();
struct ServerAcc {
calls: u64,
call_chars: u64,
result_chars: u64,
}
let mut servers: BTreeMap<String, ServerAcc> = BTreeMap::new();
let mut id_to_server: HashMap<String, String> = HashMap::new();
let mut id_to_tool: HashMap<String, String> = HashMap::new();
let mut mcp_servers: BTreeSet<String> = BTreeSet::new();
let mut deferred_tools: u64 = 0;
let mut skills_listed: u64 = 0;
let mut mcp_instruction_chars: u64 = 0;
let mut attachment_chars: u64 = 0;
let mut total_compactions: u64 = 0;
let mut max_startup_overhead: Option<(String, u64)> = None;
for session in sessions {
let mut tally = SourceTally::default();
let mut compactions: u64 = 0;
let mut first_usage: Option<Usage> = None;
let mut peak: Option<u64> = None;
let mut final_ctx: Option<u64> = None;
for event in &session.events {
if !in_window(since, event) {
continue;
}
for call in &event.tool_calls {
if let Some(server) = &call.server {
mcp_servers.insert(server.clone());
}
let key = call
.server
.clone()
.unwrap_or_else(|| ServerBreakdown::BUILTIN.to_string());
let acc = servers.entry(key.clone()).or_insert(ServerAcc {
calls: 0,
call_chars: 0,
result_chars: 0,
});
acc.calls += 1;
acc.call_chars += call.input_bytes;
id_to_server.insert(call.id.clone(), key);
id_to_tool.insert(call.id.clone(), call.name.clone());
}
if event.kind == EventKind::Assistant {
if let Some(u) = event.usage {
if first_usage.is_none() {
first_usage = Some(u);
}
let c = ctx_tokens(&u);
peak = Some(peak.map_or(c, |p| p.max(c)));
final_ctx = Some(c);
}
}
match event.kind {
EventKind::User => {
tally.user_prompts += event.content_chars;
global_sources.user_prompts += event.content_chars;
if event.content_chars > 0 {
heaviest.push(HeavyItem {
source: ContextSource::UserPrompts,
label: event.content_summary.clone(),
session_id: session.id.clone(),
chars: event.content_chars,
est_tokens: 0,
});
}
}
EventKind::ToolResult => {
tally.tool_results += event.content_chars;
global_sources.tool_results += event.content_chars;
if event.content_chars > 0 {
let label = event
.tool_use_id
.as_ref()
.and_then(|id| id_to_tool.get(id).cloned());
heaviest.push(HeavyItem {
source: ContextSource::ToolResults,
label,
session_id: session.id.clone(),
chars: event.content_chars,
est_tokens: 0,
});
}
}
EventKind::Attachment => {
tally.attachments += event.content_chars;
global_sources.attachments += event.content_chars;
attachment_chars += event.content_chars;
match event.attachment_kind.as_deref() {
Some("deferred_tools_delta") => {
deferred_tools = deferred_tools.max(event.item_count);
}
Some("skill_listing") => {
skills_listed = skills_listed.max(event.item_count);
}
Some("mcp_instructions_delta") => {
mcp_instruction_chars += event.content_chars;
}
_ => {}
}
}
EventKind::Assistant | EventKind::ToolCall => {
let tc = tool_call_bytes(event);
let text = event
.content_chars
.saturating_sub(event.thinking_chars + tc);
tally.tool_calls += tc;
tally.thinking += event.thinking_chars;
tally.assistant_text += text;
global_sources.tool_calls += tc;
global_sources.thinking += event.thinking_chars;
global_sources.assistant_text += text;
if text > 0 {
heaviest.push(HeavyItem {
source: ContextSource::AssistantText,
label: event.content_summary.clone(),
session_id: session.id.clone(),
chars: text,
est_tokens: 0,
});
}
if event.thinking_chars > 0 {
heaviest.push(HeavyItem {
source: ContextSource::Thinking,
label: None,
session_id: session.id.clone(),
chars: event.thinking_chars,
est_tokens: 0,
});
}
for call in &event.tool_calls {
if call.input_bytes > 0 {
heaviest.push(HeavyItem {
source: ContextSource::ToolCalls,
label: Some(call.name.clone()),
session_id: session.id.clone(),
chars: call.input_bytes,
est_tokens: 0,
});
}
}
}
EventKind::Compaction => {
compactions += 1;
}
EventKind::System | EventKind::SubAgentSpawn => {}
}
}
total_compactions += compactions;
let cold_start = first_usage.is_some_and(|u| u.cache_read == Some(0));
let startup_overhead_tokens = if cold_start {
first_usage.map(|u| u.input.unwrap_or(0) + u.cache_creation.unwrap_or(0))
} else {
None
};
if let Some(v) = startup_overhead_tokens {
match &max_startup_overhead {
Some((_, best)) if *best >= v => {}
_ => max_startup_overhead = Some((session.id.clone(), v)),
}
}
let total_content_chars = tally.total();
if first_usage.is_none() && total_content_chars == 0 {
continue;
}
session_rows.push(SessionContext {
id: session.id.clone(),
project: session.project.clone(),
model: session.model.clone(),
sidechain: session.parent_session.is_some(),
startup_overhead_tokens,
cold_start,
peak_input_tokens: peak,
final_input_tokens: final_ctx,
sources: tally.breakdown(),
total_content_chars,
compactions,
});
}
for session in sessions {
for event in &session.events {
if !in_window(since, event) {
continue;
}
if event.kind != EventKind::ToolResult {
continue;
}
let key = event
.tool_use_id
.as_ref()
.and_then(|id| id_to_server.get(id).cloned())
.unwrap_or_else(|| ServerBreakdown::BUILTIN.to_string());
servers
.entry(key)
.or_insert(ServerAcc {
calls: 0,
call_chars: 0,
result_chars: 0,
})
.result_chars += event.content_chars;
}
}
let mut by_server: Vec<ServerBreakdown> = servers
.into_iter()
.map(|(server, acc)| ServerBreakdown {
server,
calls: acc.calls,
call_chars: acc.call_chars,
result_chars: acc.result_chars,
est_tokens: super::est_tokens(acc.call_chars + acc.result_chars),
})
.collect();
by_server.sort_by(|a, b| {
b.est_tokens
.cmp(&a.est_tokens)
.then_with(|| a.server.cmp(&b.server))
});
heaviest.sort_by(|a, b| {
b.chars
.cmp(&a.chars)
.then_with(|| a.session_id.cmp(&b.session_id))
});
heaviest.truncate(HEAVIEST_N);
for item in &mut heaviest {
item.est_tokens = super::est_tokens(item.chars);
}
session_rows.sort_by(|a, b| match (b.peak_input_tokens, a.peak_input_tokens) {
(Some(x), Some(y)) => x.cmp(&y).then_with(|| a.id.cmp(&b.id)),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => a.id.cmp(&b.id),
});
ContextReport {
agent: agent.to_string(),
since,
sessions_profiled: session_rows.len() as u64,
sessions: session_rows,
by_source: global_sources.breakdown(),
by_server,
heaviest,
mcp_servers: mcp_servers.into_iter().collect(),
deferred_tools,
skills_listed,
mcp_instruction_chars,
attachment_chars,
compactions: total_compactions,
max_startup_overhead,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Event, ToolCall};
use jiff::Timestamp;
const TS: &str = "2026-06-02T10:00:00Z";
fn ev(kind: EventKind) -> Event {
Event {
kind,
ts: Some(TS.parse().unwrap()),
request_id: None,
model: None,
usage: None,
tool_calls: Vec::new(),
sidechain: false,
content_summary: None,
content_chars: 0,
thinking_chars: 0,
has_thinking: false,
tool_use_id: None,
attachment_kind: None,
item_count: 0,
}
}
fn usage(input: u64, cc: u64, cr: Option<u64>) -> Usage {
Usage {
input: Some(input),
output: Some(0),
cache_creation: Some(cc),
cache_read: cr,
..Usage::default()
}
}
fn asst_usage(u: Usage) -> Event {
let mut e = ev(EventKind::Assistant);
e.usage = Some(u);
e
}
fn call(id: &str, name: &str, bytes: u64) -> ToolCall {
ToolCall {
id: id.into(),
name: name.into(),
input_bytes: bytes,
server: ToolCall::server_from_name(name),
}
}
fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
Session {
id: id.into(),
agent: "claude-code".into(),
project: Some("proj".into()),
model: Some("claude-sonnet-4-5".into()),
parent_session: parent.map(str::to_string),
started_at: None,
ended_at: None,
events,
sub_agents: Vec::new(),
skipped_lines: 0,
}
}
fn src(b: &[SourceBreakdown], s: ContextSource) -> Option<&SourceBreakdown> {
b.iter().find(|x| x.source == s)
}
fn server<'a>(b: &'a [ServerBreakdown], name: &str) -> Option<&'a ServerBreakdown> {
b.iter().find(|x| x.server == name)
}
#[test]
fn cold_start_isolates_startup_overhead() {
let s = session("s1", None, vec![asst_usage(usage(3, 18104, Some(0)))]);
let r = profile(&[s], "claude-code", None);
let sc = &r.sessions[0];
assert!(sc.cold_start);
assert_eq!(sc.startup_overhead_tokens, Some(18107));
assert_eq!(sc.peak_input_tokens, Some(18107));
assert_eq!(sc.final_input_tokens, Some(18107));
assert_eq!(r.max_startup_overhead, Some(("s1".to_string(), 18107)));
}
#[test]
fn warm_resume_reports_overhead_as_unknown() {
let s = session("s1", None, vec![asst_usage(usage(10, 0, Some(500)))]);
let r = profile(&[s], "claude-code", None);
let sc = &r.sessions[0];
assert!(!sc.cold_start);
assert_eq!(sc.startup_overhead_tokens, None);
assert_eq!(sc.peak_input_tokens, Some(510));
assert_eq!(r.max_startup_overhead, None);
}
#[test]
fn unknown_cache_read_is_not_cold_start() {
let s = session("s1", None, vec![asst_usage(usage(10, 0, None))]);
let r = profile(&[s], "claude-code", None);
let sc = &r.sessions[0];
assert!(!sc.cold_start);
assert_eq!(sc.startup_overhead_tokens, None);
assert_eq!(sc.final_input_tokens, Some(10));
}
#[test]
fn peak_is_max_and_final_is_last_request() {
let s = session(
"s1",
None,
vec![
asst_usage(usage(100, 0, Some(0))),
asst_usage(usage(0, 0, Some(5000))),
asst_usage(usage(300, 0, Some(500))),
],
);
let r = profile(&[s], "claude-code", None);
let sc = &r.sessions[0];
assert_eq!(sc.peak_input_tokens, Some(5000));
assert_eq!(sc.final_input_tokens, Some(800));
assert!(sc.cold_start);
assert_eq!(sc.startup_overhead_tokens, Some(100));
}
#[test]
fn assistant_event_splits_text_thinking_and_tool_calls() {
let mut a = ev(EventKind::Assistant);
a.content_chars = 100;
a.thinking_chars = 30;
a.tool_calls = vec![call("t1", "Read", 20)];
let s = session("s1", None, vec![a]);
let r = profile(&[s], "claude-code", None);
let sc = &r.sessions[0];
assert_eq!(sc.total_content_chars, 100, "50 text + 30 think + 20 call");
assert_eq!(
src(&sc.sources, ContextSource::AssistantText)
.unwrap()
.chars,
50
);
assert_eq!(src(&sc.sources, ContextSource::Thinking).unwrap().chars, 30);
assert_eq!(
src(&sc.sources, ContextSource::ToolCalls).unwrap().chars,
20
);
let at = src(&sc.sources, ContextSource::AssistantText).unwrap();
assert!((at.share - 0.5).abs() < 1e-9);
assert_eq!(at.est_tokens, 12, "50 / 4 = 12");
assert_eq!(sc.sources[0].source, ContextSource::AssistantText);
assert_eq!(sc.sources[1].source, ContextSource::Thinking);
assert_eq!(sc.sources[2].source, ContextSource::ToolCalls);
}
#[test]
fn source_split_across_event_kinds() {
let mut user = ev(EventKind::User);
user.content_chars = 40;
let mut result = ev(EventKind::ToolResult);
result.content_chars = 1000;
result.tool_use_id = Some("t1".into());
let mut attach = ev(EventKind::Attachment);
attach.content_chars = 8;
let mut a = ev(EventKind::Assistant);
a.content_chars = 12; let s = session("s1", None, vec![user, a, result, attach]);
let r = profile(&[s], "claude-code", None);
let sc = &r.sessions[0];
assert_eq!(sc.total_content_chars, 40 + 12 + 1000 + 8);
assert_eq!(
src(&sc.sources, ContextSource::UserPrompts).unwrap().chars,
40
);
assert_eq!(
src(&sc.sources, ContextSource::ToolResults).unwrap().chars,
1000
);
assert_eq!(
src(&sc.sources, ContextSource::Attachments).unwrap().chars,
8
);
assert_eq!(
src(&sc.sources, ContextSource::AssistantText)
.unwrap()
.chars,
12
);
assert_eq!(sc.sources[0].source, ContextSource::ToolResults);
assert_eq!(
src(&r.by_source, ContextSource::ToolResults).unwrap().chars,
1000
);
}
#[test]
fn server_attribution_groups_calls_and_results() {
let mut a = ev(EventKind::Assistant);
a.tool_calls = vec![
call("g1", "mcp__github__search", 100),
call("r1", "Read", 30),
];
let mut res_github = ev(EventKind::ToolResult);
res_github.tool_use_id = Some("g1".into());
res_github.content_chars = 4000;
let mut res_read = ev(EventKind::ToolResult);
res_read.tool_use_id = Some("r1".into());
res_read.content_chars = 200;
let mut res_orphan = ev(EventKind::ToolResult);
res_orphan.tool_use_id = Some("missing".into());
res_orphan.content_chars = 7;
let s = session("s1", None, vec![a, res_github, res_read, res_orphan]);
let r = profile(&[s], "claude-code", None);
let gh = server(&r.by_server, "github").unwrap();
assert_eq!(gh.calls, 1);
assert_eq!(gh.call_chars, 100);
assert_eq!(gh.result_chars, 4000);
assert_eq!(gh.est_tokens, (100 + 4000) / 4);
let bi = server(&r.by_server, ServerBreakdown::BUILTIN).unwrap();
assert_eq!(bi.calls, 1, "the Read call");
assert_eq!(bi.call_chars, 30);
assert_eq!(bi.result_chars, 207);
assert_eq!(r.by_server[0].server, "github");
assert_eq!(r.mcp_servers, vec!["github".to_string()]);
}
#[test]
fn heaviest_ranks_biggest_contributor_first() {
let mut a = ev(EventKind::Assistant);
a.content_chars = 60; a.content_summary = Some("hello".into());
let mut big = ev(EventKind::ToolResult);
big.tool_use_id = Some("t1".into());
big.content_chars = 9000;
let mut small = ev(EventKind::ToolResult);
small.content_chars = 100;
let mut a2 = ev(EventKind::Assistant);
a2.tool_calls = vec![call("t1", "Grep", 25)];
let s = session("s1", None, vec![a, a2, big, small]);
let r = profile(&[s], "claude-code", None);
assert_eq!(r.heaviest[0].chars, 9000);
assert_eq!(r.heaviest[0].source, ContextSource::ToolResults);
assert_eq!(r.heaviest[0].label.as_deref(), Some("Grep"));
assert_eq!(r.heaviest[0].est_tokens, 2250);
let chars: Vec<u64> = r.heaviest.iter().map(|h| h.chars).collect();
assert_eq!(chars, vec![9000, 100, 60, 25]);
}
#[test]
fn inventory_counts_deferred_skills_servers_and_compactions() {
let mut deferred1 = ev(EventKind::Attachment);
deferred1.attachment_kind = Some("deferred_tools_delta".into());
deferred1.item_count = 30;
deferred1.content_chars = 5;
let mut deferred2 = ev(EventKind::Attachment);
deferred2.attachment_kind = Some("deferred_tools_delta".into());
deferred2.item_count = 42; deferred2.content_chars = 5;
let mut skills = ev(EventKind::Attachment);
skills.attachment_kind = Some("skill_listing".into());
skills.item_count = 9;
skills.content_chars = 5;
let mut mcp_instr = ev(EventKind::Attachment);
mcp_instr.attachment_kind = Some("mcp_instructions_delta".into());
mcp_instr.content_chars = 800;
let mut a = ev(EventKind::Assistant);
a.tool_calls = vec![
call("g1", "mcp__github__x", 1),
call("c1", "mcp__canva__y", 1),
];
let comp = ev(EventKind::Compaction);
let s = session(
"s1",
None,
vec![deferred1, deferred2, skills, mcp_instr, a, comp],
);
let r = profile(&[s], "claude-code", None);
assert_eq!(r.deferred_tools, 42, "max item_count across deltas");
assert_eq!(r.skills_listed, 9);
assert_eq!(r.mcp_instruction_chars, 800);
assert_eq!(r.attachment_chars, 815);
assert_eq!(r.compactions, 1);
assert_eq!(r.sessions[0].compactions, 1);
assert_eq!(
r.mcp_servers,
vec!["canva".to_string(), "github".to_string()]
);
}
#[test]
fn sidechain_flag_and_empty_sessions_skipped() {
let child = session(
"agent-x",
Some("parent"),
vec![asst_usage(usage(5, 0, Some(0)))],
);
let empty = session("empty", None, vec![ev(EventKind::System)]);
let r = profile(&[child, empty], "claude-code", None);
assert_eq!(r.sessions_profiled, 1, "empty session produced no row");
assert_eq!(r.sessions[0].id, "agent-x");
assert!(r.sessions[0].sidechain);
}
#[test]
fn since_filter_excludes_older_and_undated_events() {
let mut old = asst_usage(usage(1000, 0, Some(0)));
old.ts = Some("2026-06-01T10:00:00Z".parse::<Timestamp>().unwrap());
let mut new = asst_usage(usage(50, 0, Some(200)));
new.ts = Some("2026-06-02T09:00:00Z".parse::<Timestamp>().unwrap());
let mut undated = ev(EventKind::ToolResult);
undated.ts = None;
undated.content_chars = 99999;
let since: Date = "2026-06-02".parse().unwrap();
let s = session("s1", None, vec![old, new, undated]);
let r = profile(&[s], "claude-code", Some(since));
let sc = &r.sessions[0];
assert_eq!(
sc.peak_input_tokens,
Some(250),
"50 + 200, old 1000 excluded"
);
assert_eq!(sc.final_input_tokens, Some(250));
assert!(!sc.cold_start);
assert_eq!(sc.total_content_chars, 0);
assert!(r.heaviest.is_empty(), "undated heavy result filtered out");
assert_eq!(r.since, Some(since));
}
#[test]
fn sessions_sorted_by_peak_desc_then_id() {
let a = session("aaa", None, vec![asst_usage(usage(100, 0, Some(0)))]);
let b = session("bbb", None, vec![asst_usage(usage(0, 0, Some(9000)))]);
let mut txt = ev(EventKind::User);
txt.content_chars = 10;
let c = session("ccc", None, vec![txt]);
let r = profile(&[a, b, c], "claude-code", None);
let ids: Vec<&str> = r.sessions.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["bbb", "aaa", "ccc"], "peak 9000, 100, then None");
assert_eq!(r.sessions[2].peak_input_tokens, None);
}
}