1use std::collections::BTreeMap;
20
21use jiff::Timestamp;
22use serde::Serialize;
23
24use crate::analysis::aggregate::{Filter, Rollup};
25use crate::analysis::context::{profile_refs, ContextReport};
26use crate::analysis::dedup::dedup_session;
27use crate::model::{Event, EventKind, Session, Usage};
28use crate::pricing::PricingTable;
29
30#[derive(Debug, Clone, Serialize)]
36pub struct Turn {
37 pub request_id: Option<String>,
38 pub ts: Option<Timestamp>,
39 pub model: Option<String>,
40 pub usage: Usage,
41 pub cost_usd: Option<f64>,
43 pub sidechain: bool,
46 pub summary: Option<String>,
49 pub tools: Vec<String>,
51 pub has_thinking: bool,
53 pub thinking_chars: u64,
56 pub thinking_encrypted: bool,
59}
60
61#[derive(Debug, Clone, Serialize)]
65pub struct SessionDetail {
66 pub id: String,
67 pub project: Option<String>,
68 pub model: Option<String>,
69 pub started_at: Option<Timestamp>,
70 pub ended_at: Option<Timestamp>,
71 pub rollup: Rollup,
74 pub sub_agents: u64,
77 pub sub_agent_tokens: u64,
79 pub turns: Vec<Turn>,
81 pub context: ContextReport,
84}
85
86impl SessionDetail {
87 pub fn total_tokens(&self) -> u64 {
89 self.rollup.total_tokens()
90 }
91}
92
93fn in_window(filter: &Filter, event: &Event) -> bool {
96 match (filter.since, event.ts) {
97 (None, _) => true,
98 (Some(since), Some(ts)) => super::utc_date(ts) >= since,
99 (Some(_), None) => false,
100 }
101}
102
103fn enrichment(
106 session: &Session,
107 filter: &Filter,
108) -> BTreeMap<String, (Option<String>, Vec<String>)> {
109 let mut map: BTreeMap<String, (Option<String>, Vec<String>)> = BTreeMap::new();
110 for event in &session.events {
111 if event.kind != EventKind::Assistant || !in_window(filter, event) {
112 continue;
113 }
114 let Some(rid) = &event.request_id else {
115 continue;
116 };
117 let entry = map.entry(rid.clone()).or_default();
118 if entry.0.is_none() {
119 if let Some(s) = &event.content_summary {
120 if !s.is_empty() {
121 entry.0 = Some(s.clone());
122 }
123 }
124 }
125 for call in &event.tool_calls {
126 if !entry.1.contains(&call.name) {
127 entry.1.push(call.name.clone());
128 }
129 }
130 }
131 map
132}
133
134fn session_turns(session: &Session, filter: &Filter, pricing: &PricingTable) -> Vec<Turn> {
136 let enrich = enrichment(session, filter);
137 let (records, _stats) = dedup_session(session, filter.since);
138 records
139 .into_iter()
140 .map(|rec| {
141 let (summary, tools) = rec
142 .request_id
143 .as_ref()
144 .and_then(|rid| enrich.get(rid))
145 .cloned()
146 .unwrap_or_default();
147 Turn {
148 cost_usd: pricing.cost_usd(rec.model.as_deref(), &rec.usage),
149 request_id: rec.request_id,
150 ts: rec.ts,
151 model: rec.model,
152 usage: rec.usage,
153 sidechain: rec.sidechain,
154 summary,
155 tools,
156 has_thinking: rec.has_thinking,
157 thinking_chars: rec.thinking_chars,
158 thinking_encrypted: rec.thinking_encrypted,
159 }
160 })
161 .collect()
162}
163
164struct Acc<'a> {
166 files: Vec<&'a Session>,
167 parent: Option<&'a Session>,
168 spawn_calls: u64,
169 children_folded: u64,
170}
171
172pub fn build_details(
177 sessions: &[Session],
178 filter: &Filter,
179 agent: &str,
180 pricing: &PricingTable,
181) -> Vec<SessionDetail> {
182 let mut rows: BTreeMap<String, Acc> = BTreeMap::new();
184 for session in sessions {
185 let row_id = session
186 .parent_session
187 .clone()
188 .unwrap_or_else(|| session.id.clone());
189 let acc = rows.entry(row_id).or_insert_with(|| Acc {
190 files: Vec::new(),
191 parent: None,
192 spawn_calls: 0,
193 children_folded: 0,
194 });
195 acc.files.push(session);
196 if session.parent_session.is_some() {
197 acc.children_folded += 1;
198 } else {
199 acc.parent = Some(session);
200 acc.spawn_calls += session.sub_agents.len() as u64;
201 }
202 }
203
204 let mut details: Vec<SessionDetail> = rows
205 .into_iter()
206 .filter_map(|(id, acc)| {
207 let meta = acc.parent.or_else(|| acc.files.first().copied());
209
210 let mut rollup = Rollup::default();
211 let mut sub_agent_tokens = 0u64;
212 let mut turns: Vec<Turn> = Vec::new();
213 for file in &acc.files {
214 let (records, _) = dedup_session(file, filter.since);
215 for rec in &records {
216 rollup.add(rec, pricing);
217 if rec.sidechain {
218 sub_agent_tokens += rec.usage.known_total();
219 }
220 }
221 turns.extend(session_turns(file, filter, pricing));
222 }
223
224 if rollup.requests == 0 {
226 return None;
227 }
228
229 turns.sort_by(|a, b| match (a.ts, b.ts) {
231 (Some(x), Some(y)) => x.cmp(&y),
232 (Some(_), None) => std::cmp::Ordering::Less,
233 (None, Some(_)) => std::cmp::Ordering::Greater,
234 (None, None) => std::cmp::Ordering::Equal,
235 });
236
237 let context = profile_refs(&acc.files, agent, filter.since);
238
239 Some(SessionDetail {
240 id,
241 project: meta.and_then(|s| s.project.clone()),
242 model: meta.and_then(|s| s.model.clone()),
243 started_at: meta.and_then(|s| s.started_at),
244 ended_at: meta.and_then(|s| s.ended_at),
245 sub_agents: acc.spawn_calls.max(acc.children_folded),
248 sub_agent_tokens,
249 turns,
250 rollup,
251 context,
252 })
253 })
254 .collect();
255
256 details.sort_by(|a, b| {
257 b.rollup
258 .cost_usd
259 .total_cmp(&a.rollup.cost_usd)
260 .then_with(|| b.total_tokens().cmp(&a.total_tokens()))
261 .then_with(|| a.id.cmp(&b.id))
262 });
263 details
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::model::{ToolCall, Usage};
270
271 fn ev(kind: EventKind) -> Event {
272 Event {
273 kind,
274 ts: Some("2026-06-02T10:00:00Z".parse().unwrap()),
275 request_id: None,
276 model: Some("claude-sonnet-4-5".into()),
277 usage: None,
278 tool_calls: Vec::new(),
279 sidechain: false,
280 content_summary: None,
281 content_chars: 0,
282 thinking_chars: 0,
283 has_thinking: false,
284 tool_use_id: None,
285 attachment_kind: None,
286 item_count: 0,
287 }
288 }
289
290 fn usage(input: u64, output: u64) -> Usage {
291 Usage {
292 input: Some(input),
293 output: Some(output),
294 cache_creation: Some(0),
295 cache_read: Some(0),
296 ..Usage::default()
297 }
298 }
299
300 fn turn(rid: &str, ts: &str, u: Usage) -> Event {
302 let mut e = ev(EventKind::Assistant);
303 e.request_id = Some(rid.into());
304 e.ts = Some(ts.parse().unwrap());
305 e.usage = Some(u);
306 e
307 }
308
309 fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
310 Session {
311 id: id.into(),
312 agent: "claude-code".into(),
313 project: Some("proj".into()),
314 model: Some("claude-sonnet-4-5".into()),
315 parent_session: parent.map(str::to_string),
316 started_at: None,
317 ended_at: None,
318 events,
319 sub_agents: Vec::new(),
320 skipped_lines: 0,
321 }
322 }
323
324 #[test]
327 fn child_folds_into_parent_with_marked_turns() {
328 let mut p = turn("req_p", "2026-06-01T10:00:00Z", usage(1000, 200));
329 p.content_summary = Some("parent does a thing".into());
330 p.tool_calls = vec![ToolCall {
331 id: "t1".into(),
332 name: "Read".into(),
333 input_bytes: 20,
334 server: None,
335 }];
336 let parent = session("parent", None, vec![p]);
337 let child = session(
338 "agent-x",
339 Some("parent"),
340 vec![turn("req_c", "2026-06-01T10:05:00Z", usage(500, 50))],
341 );
342
343 let details = build_details(
344 &[parent, child],
345 &Filter::default(),
346 "claude-code",
347 &PricingTable::embedded(),
348 );
349 assert_eq!(details.len(), 1, "child folded, not its own row");
350 let d = &details[0];
351 assert_eq!(d.id, "parent");
352 assert_eq!(d.rollup.input, 1500, "parent 1000 + child 500");
353 assert_eq!(d.sub_agents, 1);
354 assert_eq!(d.sub_agent_tokens, 550, "child known_total = 500+50");
355
356 assert_eq!(d.turns.len(), 2);
357 assert_eq!(d.turns[0].request_id.as_deref(), Some("req_p"));
359 assert!(!d.turns[0].sidechain);
360 assert_eq!(d.turns[0].summary.as_deref(), Some("parent does a thing"));
361 assert_eq!(d.turns[0].tools, vec!["Read".to_string()]);
362 assert_eq!(d.turns[1].request_id.as_deref(), Some("req_c"));
363 assert!(d.turns[1].sidechain, "child turn marked as sub-agent");
364 }
365
366 #[test]
368 fn turn_cost_priced_and_unpriced() {
369 let priced = session(
370 "a",
371 None,
372 vec![turn("r", "2026-06-02T10:00:00Z", usage(1000, 0))],
373 );
374 let mut unp = turn("r2", "2026-06-02T10:00:00Z", usage(1000, 0));
375 unp.model = Some("claude-opus-5-0".into()); let unpriced = session("b", None, vec![unp]);
377
378 let details = build_details(
379 &[priced, unpriced],
380 &Filter::default(),
381 "claude-code",
382 &PricingTable::embedded(),
383 );
384 let a = details.iter().find(|d| d.id == "a").unwrap();
385 let b = details.iter().find(|d| d.id == "b").unwrap();
386 assert!((a.turns[0].cost_usd.unwrap() - 0.003).abs() < 1e-9);
388 assert_eq!(b.turns[0].cost_usd, None, "unpriced model -> no guess");
389 }
390
391 #[test]
393 fn details_sorted_by_cost_desc() {
394 let small = session(
395 "small",
396 None,
397 vec![turn("r1", "2026-06-02T10:00:00Z", usage(100, 0))],
398 );
399 let big = session(
400 "big",
401 None,
402 vec![turn("r2", "2026-06-02T10:00:00Z", usage(100_000, 0))],
403 );
404 let details = build_details(
405 &[small, big],
406 &Filter::default(),
407 "claude-code",
408 &PricingTable::embedded(),
409 );
410 let ids: Vec<&str> = details.iter().map(|d| d.id.as_str()).collect();
411 assert_eq!(ids, vec!["big", "small"]);
412 }
413
414 #[test]
416 fn empty_session_is_dropped() {
417 let empty = session("empty", None, vec![ev(EventKind::System)]);
418 let details = build_details(
419 &[empty],
420 &Filter::default(),
421 "claude-code",
422 &PricingTable::embedded(),
423 );
424 assert!(details.is_empty());
425 }
426
427 #[test]
429 fn duplicate_request_lines_collapse_to_one_turn() {
430 let u = usage(1000, 200);
431 let s = session(
432 "s",
433 None,
434 vec![
435 turn("req_1", "2026-06-02T10:00:00Z", u),
436 turn("req_1", "2026-06-02T10:00:00Z", u),
437 turn("req_1", "2026-06-02T10:00:00Z", u),
438 ],
439 );
440 let details = build_details(
441 &[s],
442 &Filter::default(),
443 "claude-code",
444 &PricingTable::embedded(),
445 );
446 assert_eq!(details[0].turns.len(), 1, "3 lines, 1 request -> 1 turn");
447 assert_eq!(details[0].rollup.input, 1000, "not multiplied by 3");
448 }
449}