Skip to main content

skiagram_core/analysis/
drilldown.rs

1//! Per-session drill-down view-models for the TUI (session → turns → context).
2//!
3//! This module re-derives nothing: it composes the authoritative passes so the
4//! interactive view shows the same numbers as the `summary`/`context` commands.
5//!
6//! - per-turn usage comes from [`super::dedup::dedup_session`] — request-level
7//!   dedup is THE accounting step (CLAUDE.md §8.1);
8//! - per-turn cost from [`crate::pricing::cost_usd`] (every figure traces to a
9//!   unit price, §8.7);
10//! - the per-session context-bloat breakdown from [`super::context::profile_refs`]
11//!   (§2.2), scoped to just this session's files;
12//! - sub-agent transcripts fold into their parent session exactly as
13//!   [`super::aggregate`] does — same row id, nothing dropped (§8.3).
14//!
15//! The display-only fields on a [`Turn`] (a short content snippet, the tool names
16//! touched in the request) are joined on by `request_id` and never influence
17//! accounting — if the join misses, the number is still right, just unlabeled.
18
19use 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/// One deduplicated API request in a session, enriched for display.
31///
32/// `usage` is the finalized per-request usage (post-dedup); `cost_usd` is `None`
33/// when the model is unpriced (§8.7 — never guessed). `summary`/`tools` are
34/// display-only and may be empty without affecting the numbers.
35#[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    /// USD cost of this request, or `None` when the model is unpriced.
42    pub cost_usd: Option<f64>,
43    /// The request came from a sub-agent (sidechain) transcript folded into this
44    /// session (§8.3).
45    pub sidechain: bool,
46    /// Short visible-text snippet for display (first non-empty across the
47    /// request's lines), when available.
48    pub summary: Option<String>,
49    /// Tool names invoked in this request, in first-seen order (deduplicated).
50    pub tools: Vec<String>,
51    /// Thinking blocks were present (measurable or encrypted).
52    pub has_thinking: bool,
53    /// Visible thinking chars in this request — already inside `usage.output`
54    /// (Claude Code), surfaced for attribution, never billed again (§8.2).
55    pub thinking_chars: u64,
56    /// Thinking present but encrypted/unmeasurable: the thinking share of output
57    /// is unknown (absence ≠ zero, §8.5).
58    pub thinking_encrypted: bool,
59}
60
61/// Everything the drill-down needs about one session row: the same folded,
62/// sub-agent-inclusive accounting as a `summary` row, plus its turn list and its
63/// context-bloat breakdown.
64#[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    /// Token + cost rollup over all turns (parent + folded sub-agents), identical
72    /// to this session's row in the `summary`.
73    pub rollup: Rollup,
74    /// Sub-agents attributed to this session (stronger of spawn calls / folded
75    /// child transcripts), as in [`super::aggregate`].
76    pub sub_agents: u64,
77    /// Known tokens contributed by folded sub-agent transcripts.
78    pub sub_agent_tokens: u64,
79    /// Deduplicated requests, oldest first (undated last), sub-agent turns marked.
80    pub turns: Vec<Turn>,
81    /// Context-bloat breakdown scoped to this session's files (parent + children),
82    /// reusing [`super::context::profile_refs`].
83    pub context: ContextReport,
84}
85
86impl SessionDetail {
87    /// Total deduplicated tokens for the row (sort key shared with `summary`).
88    pub fn total_tokens(&self) -> u64 {
89        self.rollup.total_tokens()
90    }
91}
92
93/// Window predicate matching [`super::dedup`]/[`super::context`]: with `since`
94/// set, an event is in range only if dated on/after it; undated events drop.
95fn 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
103/// Map `request_id -> (first content snippet, tool names)` over a session's
104/// in-window assistant events, for display-only enrichment of dedup records.
105fn 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
134/// Turn rows for a single session file, dedup-correct and enriched for display.
135fn 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
164/// Accumulator for one session row (parent + folded sub-agent files).
165struct Acc<'a> {
166    files: Vec<&'a Session>,
167    parent: Option<&'a Session>,
168    spawn_calls: u64,
169    children_folded: u64,
170}
171
172/// Build one [`SessionDetail`] per session row, folding sub-agent transcripts into
173/// their parent (§8.3) and sorting the same way `summary` sorts its sessions:
174/// cost desc, then total tokens desc, then id. Rows with no requests are dropped
175/// (mirrors [`super::aggregate`]), so the list matches the `summary`.
176pub fn build_details(
177    sessions: &[Session],
178    filter: &Filter,
179    agent: &str,
180    pricing: &PricingTable,
181) -> Vec<SessionDetail> {
182    // Group by row id = parent_session.unwrap_or(id), exactly like `aggregate`.
183    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            // Metadata from the parent file when present, else the first file seen.
208            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            // Drop empty rows so the list matches `summary` (which keeps requests>0).
225            if rollup.requests == 0 {
226                return None;
227            }
228
229            // Oldest first; undated turns sort last, stably.
230            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                // A spawn call and a folded child are usually the same sub-agent
246                // seen from both sides — take the stronger evidence (as aggregate).
247                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    /// Assistant turn at a given timestamp with a request id and usage.
301    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    /// §8.3: a sub-agent transcript folds into the parent row; turns from both
325    /// appear, sub-agent turns marked, tokens attributed not dropped.
326    #[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        // Oldest first: parent turn, then child turn.
358        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    /// Per-turn cost traces to the unit price; unpriced models surface as None.
367    #[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()); // post-snapshot, unpriced
376        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        // 1000 input * $3/M = $0.003.
387        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    /// Rows are sorted like `summary`: by cost desc.
392    #[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    /// A session with no accountable usage produces no row (mirrors aggregate).
415    #[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    /// Duplicate lines of one request collapse to a single turn (§8.1).
428    #[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}