Skip to main content

mj_controller/database/
usage.rs

1use super::*;
2#[cfg(test)]
3use mj_core::usage::ProviderCost;
4use mj_core::usage::{TokenUsage, UsageScope};
5
6pub(super) fn record_child_accounting(
7    connection: &Connection,
8    child: &SubagentRecord,
9) -> Result<()> {
10    connection.execute(
11        "INSERT INTO subagent_accounting(child_session_id, parent_session_id, task_name)
12        VALUES (?1, ?2, ?3) ON CONFLICT(child_session_id) DO NOTHING",
13        params![
14            child.child_session_id,
15            child.parent_session_id,
16            child.task_name
17        ],
18    )?;
19    let stored: (String, String) = connection.query_row(
20        "SELECT parent_session_id, task_name FROM subagent_accounting WHERE child_session_id=?1",
21        [&child.child_session_id],
22        |row| Ok((row.get(0)?, row.get(1)?)),
23    )?;
24    ensure!(
25        stored == (child.parent_session_id.clone(), child.task_name.clone()),
26        "accounting identity changed for child {}",
27        child.child_session_id
28    );
29    Ok(())
30}
31
32pub(super) fn record_turn_selection(
33    connection: &Connection,
34    session: &str,
35    command: &str,
36    configuration: &mj_core::state::SessionConfiguration,
37) -> Result<()> {
38    let surface =
39        mj_core::acp::surface::AcpSessionSurface::from_configuration(&configuration.values);
40    connection.execute(
41        "INSERT INTO session_turn_selections(session_id, command_id, model, effort)
42        VALUES (?1, ?2, ?3, ?4) ON CONFLICT(session_id, command_id) DO NOTHING",
43        params![
44            session,
45            command,
46            surface.current_model(),
47            surface.current_effort()
48        ],
49    )?;
50    Ok(())
51}
52
53pub fn load_session_usage(
54    session_id: &str,
55    after_seq: u64,
56    limit: usize,
57) -> Result<Option<UsagePage>> {
58    load_session_usage_from(&database_path(), session_id, after_seq, limit)
59}
60
61fn context_exists(connection: &Connection, session: &str) -> Result<bool> {
62    Ok(connection.query_row(
63        "SELECT EXISTS(SELECT 1 FROM session_contexts WHERE session_id=?1)",
64        [session],
65        |row| row.get(0),
66    )?)
67}
68
69fn load_session_usage_from(
70    path: &Path,
71    session_id: &str,
72    after_seq: u64,
73    limit: usize,
74) -> Result<Option<UsagePage>> {
75    let mut connection = open_reader(path)?;
76    let tx = connection.transaction()?;
77    if !context_exists(&tx, session_id)? {
78        return Ok(None);
79    }
80    let latest_seq: u64 = tx.query_row(
81        "SELECT COALESCE(MAX(completed_ordinal), 0) FROM session_turn_usage WHERE session_id=?1",
82        [session_id],
83        |row| row.get(0),
84    )?;
85    let mut statement = tx.prepare("SELECT body FROM session_turn_usage WHERE session_id=?1 AND completed_ordinal > ?2 ORDER BY completed_ordinal LIMIT ?3")?;
86    let turns = statement
87        .query_map(
88            params![session_id, after_seq, limit.clamp(1, 1000) as i64],
89            |row| row.get::<_, String>(0),
90        )?
91        .map(|row| Ok(serde_json::from_str::<MaterializedTurnOutcome>(&row?)?))
92        .collect::<Result<Vec<_>>>()?;
93    let mut turn_selections = BTreeMap::new();
94    for turn in &turns {
95        turn_selections.insert(
96            turn.command_id.clone(),
97            selection_for(&tx, session_id, &turn.command_id)?,
98        );
99    }
100    let summary = session_summary(&tx, session_id)?;
101    Ok(Some(UsagePage {
102        session_id: session_id.into(),
103        next_after_seq: turns
104            .last()
105            .map_or(latest_seq.max(after_seq), |turn| turn.completed_ordinal),
106        latest_seq,
107        turns,
108        totals: summary.totals,
109        coverage: summary.coverage,
110        provider_session_cost: summary.provider_session_cost,
111        turn_selections,
112        by_model: summary.by_model,
113    }))
114}
115
116fn selection_for(connection: &Connection, session: &str, command: &str) -> Result<UsageSelection> {
117    Ok(connection.query_row("SELECT model, effort FROM session_turn_selections WHERE session_id=?1 AND command_id=?2",
118        params![session, command], |row| Ok(UsageSelection { model: row.get(0)?, effort: row.get(1)? })).optional()?.unwrap_or_default())
119}
120
121fn counters(usage: &TokenUsage) -> BTreeMap<String, u64> {
122    let mut values = BTreeMap::from([
123        ("total_tokens".into(), usage.total_tokens),
124        ("input_tokens".into(), usage.input_tokens),
125        ("output_tokens".into(), usage.output_tokens),
126    ]);
127    for (name, value) in [
128        ("thought_tokens", usage.thought_tokens),
129        ("cached_read_tokens", usage.cached_read_tokens),
130        ("cached_write_tokens", usage.cached_write_tokens),
131    ] {
132        if let Some(value) = value {
133            values.insert(name.into(), value);
134        }
135    }
136    values
137}
138
139fn add_counters(
140    totals: &mut BTreeMap<String, UsageCounterTotal>,
141    values: &BTreeMap<String, u64>,
142) -> Result<()> {
143    for (name, tokens) in values {
144        let total = totals.entry(name.clone()).or_insert(UsageCounterTotal {
145            tokens: 0,
146            reported_turns: 0,
147        });
148        total.tokens = total
149            .tokens
150            .checked_add(*tokens)
151            .context("usage token total overflow")?;
152        total.reported_turns = total
153            .reported_turns
154            .checked_add(1)
155            .context("usage turn total overflow")?;
156    }
157    Ok(())
158}
159
160/// Attribute each counter once. Partial or inconsistent breakdowns retain an
161/// unknown remainder instead of assigning it to the configured model.
162fn attribute(
163    usage: &TokenUsage,
164    selected: UsageSelection,
165    groups: &mut BTreeMap<UsageSelection, BTreeMap<String, UsageCounterTotal>>,
166) -> Result<()> {
167    let values = counters(usage);
168    let breakdown = usage
169        .provider_details
170        .as_ref()
171        .map(|details| &details.model_usage)
172        .filter(|models| !models.is_empty());
173    let Some(models) = breakdown else {
174        return add_counters(groups.entry(selected).or_default(), &values);
175    };
176    let mut assigned: BTreeMap<UsageSelection, BTreeMap<String, u64>> = BTreeMap::new();
177    for (counter, total) in values {
178        let parts = models
179            .iter()
180            .map(|(model, report)| (model, report.scope, counters(report).get(&counter).copied()))
181            .collect::<Vec<_>>();
182        let sum = parts.iter().try_fold(0u64, |sum, (_, scope, count)| {
183            if *scope != UsageScope::Turn {
184                return None;
185            }
186            sum.checked_add((*count)?)
187        });
188        if let Some(sum) = sum.filter(|sum| *sum <= total) {
189            for (model, _, count) in parts {
190                let selection = UsageSelection {
191                    model: Some(model.clone()),
192                    effort: (selected.model.as_ref() == Some(model))
193                        .then(|| selected.effort.clone())
194                        .flatten(),
195                };
196                assigned
197                    .entry(selection)
198                    .or_default()
199                    .insert(counter.clone(), count.expect("validated counter"));
200            }
201            if sum < total {
202                assigned
203                    .entry(UsageSelection::default())
204                    .or_default()
205                    .insert(counter, total - sum);
206            }
207        } else {
208            assigned
209                .entry(UsageSelection::default())
210                .or_default()
211                .insert(counter, total);
212        }
213    }
214    for (selection, values) in assigned {
215        add_counters(groups.entry(selection).or_default(), &values)?;
216    }
217    Ok(())
218}
219
220fn session_summary(connection: &Connection, session: &str) -> Result<UsageSummary> {
221    let mut summary = UsageSummary::default();
222    let mut groups = BTreeMap::new();
223    let mut statement = connection.prepare("SELECT u.body, s.model, s.effort FROM session_turn_usage u
224        LEFT JOIN session_turn_selections s USING(session_id, command_id) WHERE u.session_id=?1 ORDER BY u.completed_ordinal")?;
225    let rows = statement.query_map([session], |row| {
226        Ok((
227            row.get::<_, String>(0)?,
228            UsageSelection {
229                model: row.get(1)?,
230                effort: row.get(2)?,
231            },
232        ))
233    })?;
234    for row in rows {
235        let (body, selection) = row?;
236        let turn: MaterializedTurnOutcome = serde_json::from_str(&body)?;
237        summary.coverage.recorded_turns += 1;
238        match turn.usage {
239            Some(usage) if usage.scope == UsageScope::Turn => {
240                summary.coverage.full_turn_reports += 1;
241                add_counters(&mut summary.totals, &counters(&usage))?;
242                attribute(&usage, selection, &mut groups)?;
243            }
244            Some(usage) if usage.scope == UsageScope::LastRequest => {
245                summary.coverage.last_request_reports += 1
246            }
247            Some(_) => summary.coverage.unspecified_reports += 1,
248            None => summary.coverage.missing_reports += 1,
249        }
250    }
251    summary.coverage.unfinished_turns = connection.query_row("SELECT COUNT(*) FROM session_turn_selections s
252        WHERE session_id=?1 AND NOT EXISTS(SELECT 1 FROM session_turn_usage u WHERE u.session_id=s.session_id AND u.command_id=s.command_id)",
253        [session], |row| row.get(0))?;
254    summary.by_model = groups
255        .into_iter()
256        .map(|(selection, totals)| UsageModelTotal { selection, totals })
257        .collect();
258    let cost: Option<String> = connection
259        .query_row(
260            "SELECT body FROM session_provider_cost WHERE session_id=?1",
261            [session],
262            |row| row.get(0),
263        )
264        .optional()?;
265    summary.provider_session_cost = cost.map(|body| serde_json::from_str(&body)).transpose()?;
266    Ok(summary)
267}
268
269pub fn load_usage_tree(parent: &str) -> Result<Option<UsageTree>> {
270    load_usage_tree_from(&database_path(), parent)
271}
272
273fn load_usage_tree_from(path: &Path, parent: &str) -> Result<Option<UsageTree>> {
274    let mut connection = open_reader(path)?;
275    let tx = connection.transaction()?;
276    if !context_exists(&tx, parent)? {
277        return Ok(None);
278    }
279    let mut statement = tx.prepare("WITH RECURSIVE tree(id) AS (
280        SELECT ?1 UNION SELECT a.child_session_id FROM subagent_accounting a JOIN tree t ON a.parent_session_id=t.id)
281        SELECT t.id, a.parent_session_id, a.task_name, EXISTS(SELECT 1 FROM sessions s WHERE s.session_id=t.id)
282        FROM tree t LEFT JOIN subagent_accounting a ON a.child_session_id=t.id ORDER BY t.id")?;
283    let identities = statement
284        .query_map([parent], |row| {
285            Ok((
286                row.get::<_, String>(0)?,
287                row.get::<_, Option<String>>(1)?,
288                row.get::<_, Option<String>>(2)?,
289                row.get::<_, bool>(3)?,
290            ))
291        })?
292        .collect::<rusqlite::Result<Vec<_>>>()?;
293    let mut summary = UsageSummary::default();
294    let mut groups: BTreeMap<UsageSelection, BTreeMap<String, UsageCounterTotal>> = BTreeMap::new();
295    let mut sessions = Vec::new();
296    for (session_id, parent_session_id, task_name, operational_session_present) in identities {
297        let member = session_summary(&tx, &session_id)?;
298        merge_summary(&mut summary, &member)?;
299        for group in &member.by_model {
300            merge_totals(
301                groups.entry(group.selection.clone()).or_default(),
302                &group.totals,
303            )?;
304        }
305        sessions.push(UsageTreeSession {
306            session_id,
307            parent_session_id,
308            task_name,
309            operational_session_present,
310            summary: member,
311        });
312    }
313    summary.by_model = groups
314        .into_iter()
315        .map(|(selection, totals)| UsageModelTotal { selection, totals })
316        .collect();
317    Ok(Some(UsageTree {
318        parent_session_id: parent.into(),
319        sessions,
320        summary,
321    }))
322}
323
324fn merge_totals(
325    target: &mut BTreeMap<String, UsageCounterTotal>,
326    source: &BTreeMap<String, UsageCounterTotal>,
327) -> Result<()> {
328    for (name, value) in source {
329        let total = target.entry(name.clone()).or_insert(UsageCounterTotal {
330            tokens: 0,
331            reported_turns: 0,
332        });
333        total.tokens = total
334            .tokens
335            .checked_add(value.tokens)
336            .context("usage tree token overflow")?;
337        total.reported_turns = total
338            .reported_turns
339            .checked_add(value.reported_turns)
340            .context("usage tree turn overflow")?;
341    }
342    Ok(())
343}
344
345fn merge_summary(target: &mut UsageSummary, source: &UsageSummary) -> Result<()> {
346    merge_totals(&mut target.totals, &source.totals)?;
347    let a = &mut target.coverage;
348    let b = &source.coverage;
349    for (target, value) in [
350        (&mut a.recorded_turns, b.recorded_turns),
351        (&mut a.full_turn_reports, b.full_turn_reports),
352        (&mut a.last_request_reports, b.last_request_reports),
353        (&mut a.unspecified_reports, b.unspecified_reports),
354        (&mut a.missing_reports, b.missing_reports),
355        (&mut a.unfinished_turns, b.unfinished_turns),
356    ] {
357        *target = target
358            .checked_add(value)
359            .context("usage coverage overflow")?;
360    }
361    Ok(())
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use mj_core::state::TurnOutcomeKind;
368    use mj_core::usage::{TokenUsage, UsageScope};
369
370    fn completed(command: &str, ordinal: u64, input: u64) -> MaterializedTurnOutcome {
371        serde_json::from_value(serde_json::json!({
372            "command_id":command, "completed_ordinal":ordinal, "completed_at_ms":100,
373            "outcome":{"kind":"completed","stop_reason":"EndTurn"},
374            "usage":{"scope":"turn","total_tokens":input+10,"input_tokens":input,"output_tokens":10}
375        }))
376        .unwrap()
377    }
378
379    fn configuration(model: &str, effort: &str) -> mj_core::state::SessionConfiguration {
380        serde_json::from_value(serde_json::json!({"model":model,"effort":effort})).unwrap()
381    }
382
383    #[test]
384    fn turn_selections_follow_event_order_and_survive_cleanup_and_replay() -> Result<()> {
385        let dir = tempfile::tempdir()?;
386        let path = dir.path().join("usage.sqlite");
387        save_session_to(&path, &super::super::tests::session("root", "project"))?;
388        save_session_to(&path, &super::super::tests::session("child", "project"))?;
389        let connection = open(&path)?;
390        connection.execute(
391            "INSERT INTO subagent_accounting VALUES ('child','root','investigate')",
392            [],
393        )?;
394        drop(connection);
395        let events = [
396            MaterializedSessionMutation {
397                configuration: Some(configuration("sol", "high")),
398                active_turn: Some(Some(MaterializedTurn {
399                    command_id: "one".into(),
400                    accepted_ordinal: None,
401                    turn_start_position: 1,
402                    started_at_ms: 1,
403                    steered_into: None,
404                })),
405                ..Default::default()
406            },
407            MaterializedSessionMutation {
408                active_turn: Some(None),
409                last_turn_outcome: Some(completed("one", 2, 20)),
410                ..Default::default()
411            },
412            MaterializedSessionMutation {
413                configuration: Some(configuration("luna", "low")),
414                active_turn: Some(Some(MaterializedTurn {
415                    command_id: "two".into(),
416                    accepted_ordinal: None,
417                    turn_start_position: 3,
418                    started_at_ms: 3,
419                    steered_into: None,
420                })),
421                ..Default::default()
422            },
423            MaterializedSessionMutation {
424                active_turn: Some(None),
425                last_turn_outcome: Some(completed("two", 4, 40)),
426                ..Default::default()
427            },
428            MaterializedSessionMutation {
429                active_turn: Some(Some(MaterializedTurn {
430                    command_id: "crashed".into(),
431                    accepted_ordinal: None,
432                    turn_start_position: 5,
433                    started_at_ms: 5,
434                    steered_into: None,
435                })),
436                ..Default::default()
437            },
438        ];
439        for _ in 0..2 {
440            apply_projection_page_to(&path, "child", |page| {
441                for (i, event) in events.iter().enumerate() {
442                    page.apply(
443                        i as u64 + 1,
444                        &format!("{:064x}", i),
445                        &format!("{:064x}", i + 1),
446                        event,
447                    )?;
448                }
449                Ok(())
450            })?;
451        }
452        let before = load_session_usage_from(&path, "child", 0, 1)?.unwrap();
453        assert_eq!(before.turn_selections["one"].model.as_deref(), Some("sol"));
454        assert_eq!(before.by_model.len(), 2);
455        assert_eq!(before.coverage.unfinished_turns, 1);
456        assert_eq!(before.by_model[0].selection.effort.as_deref(), Some("low"));
457        let later = load_session_usage_from(&path, "child", 2, 1)?.unwrap();
458        assert_eq!(later.turn_selections["two"].model.as_deref(), Some("luna"));
459        assert_eq!(before.totals["input_tokens"].tokens, 60);
460        delete_session_from(&path, "child")?;
461        delete_session_from(&path, "root")?;
462        assert_eq!(
463            before,
464            load_session_usage_from(&path, "child", 0, 1)?.unwrap()
465        );
466        let tree = load_usage_tree_from(&path, "root")?.unwrap();
467        assert_eq!(tree.sessions.len(), 2);
468        assert_eq!(tree.summary.totals, before.totals);
469        assert_eq!(tree.summary.by_model, before.by_model);
470        assert_eq!(tree.summary.coverage.unfinished_turns, 1);
471        assert!(tree.sessions.iter().all(|s| !s.operational_session_present));
472        assert_eq!(tree.sessions[0].task_name.as_deref(), Some("investigate"));
473        Ok(())
474    }
475
476    #[test]
477    fn tree_keeps_registered_descendants_when_operational_state_is_removed() -> Result<()> {
478        let dir = tempfile::tempdir()?;
479        let path = dir.path().join("tree.sqlite");
480        let mut state = State::default();
481        for id in ["root", "child", "grandchild"] {
482            state
483                .sessions
484                .insert(id.into(), super::super::tests::session(id, "project"));
485        }
486        {
487            let (child, parent) = ("child", "root");
488            let relation: SubagentRecord = serde_json::from_value(serde_json::json!({
489                "child_session_id":child,"parent_session_id":parent,"task_name":child,
490                "profile_id":"codex","working_directory":"","initial_prompt":"probe",
491                "request_key":child,"created_at":"2026-09-30T00:00:00Z"
492            }))?;
493            state.subagents.insert(child.into(), relation);
494        }
495        save_state_to(&path, &state)?;
496        // Operational children cannot delegate today; retained accounting
497        // still traverses deeper historical/imported relationships.
498        open(&path)?.execute(
499            "INSERT INTO subagent_accounting VALUES ('grandchild','child','nested')",
500            [],
501        )?;
502        let before = load_usage_tree_from(&path, "root")?.unwrap();
503        assert_eq!(before.sessions.len(), 3);
504        save_state_to(&path, &State::default())?;
505        let after = load_usage_tree_from(&path, "root")?.unwrap();
506        assert_eq!(after.sessions.len(), 3);
507        assert!(
508            after
509                .sessions
510                .iter()
511                .all(|s| !s.operational_session_present)
512        );
513        Ok(())
514    }
515
516    #[test]
517    fn provider_breakdowns_partition_counters_without_double_counting_or_guessing() -> Result<()> {
518        use mj_core::usage::ProviderTurnUsage;
519        let mut usage = completed("one", 1, 100).usage.unwrap();
520        let mut first = usage.clone();
521        first.input_tokens = 30;
522        first.output_tokens = 4;
523        first.total_tokens = 34;
524        let mut second = first.clone();
525        second.input_tokens = 60;
526        second.output_tokens = 6;
527        second.total_tokens = 66;
528        usage.provider_details = Some(Box::new(ProviderTurnUsage {
529            model_usage: BTreeMap::from([("a".into(), first), ("b".into(), second)]),
530            ..Default::default()
531        }));
532        let selected = UsageSelection {
533            model: Some("a".into()),
534            effort: Some("high".into()),
535        };
536        let mut groups = BTreeMap::new();
537        attribute(&usage, selected.clone(), &mut groups)?;
538        assert_eq!(groups[&selected]["input_tokens"].tokens, 30);
539        assert_eq!(
540            groups[&UsageSelection {
541                model: Some("b".into()),
542                effort: None
543            }]["input_tokens"]
544                .tokens,
545            60
546        );
547        assert_eq!(
548            groups[&UsageSelection::default()]["input_tokens"].tokens,
549            10
550        );
551        for (counter, total) in counters(&usage) {
552            assert_eq!(
553                groups
554                    .values()
555                    .filter_map(|v| v.get(&counter))
556                    .map(|v| v.tokens)
557                    .sum::<u64>(),
558                total
559            );
560        }
561        usage
562            .provider_details
563            .as_mut()
564            .unwrap()
565            .model_usage
566            .get_mut("b")
567            .unwrap()
568            .scope = UsageScope::LastRequest;
569        groups.clear();
570        attribute(&usage, selected, &mut groups)?;
571        assert_eq!(groups.len(), 1);
572        assert_eq!(
573            groups[&UsageSelection::default()]["input_tokens"].tokens,
574            100
575        );
576        Ok(())
577    }
578
579    #[test]
580    fn usage_survives_reopening_and_replay_with_honest_coverage() -> Result<()> {
581        let dir = tempfile::tempdir()?;
582        let path = dir.path().join("usage.sqlite");
583        let conn = open(&path)?;
584        // Use the same projection setup as the materialized database tests.
585        drop(conn);
586        save_session_to(&path, &super::super::tests::session("usage", "project"))?;
587
588        let mut turns = Vec::new();
589        for (i, scope) in [
590            Some(UsageScope::Turn),
591            Some(UsageScope::Turn),
592            Some(UsageScope::LastRequest),
593            None,
594            Some(UsageScope::Unspecified),
595        ]
596        .into_iter()
597        .enumerate()
598        {
599            let turn = MaterializedTurnOutcome {
600                diagnostic: Some(mj_core::diagnostic::TurnDiagnostic {
601                    message: "Usage limit exceeded".into(),
602                    code: Some("provider.auth_error".into()),
603                    http_status: Some(403),
604                    reset_at: None,
605                }),
606                command_id: format!("p{i}"),
607                accepted_ordinal: None,
608                turn_start_position: Some(i as u64 + 1),
609                completed_ordinal: i as u64 + 1,
610                completed_at_ms: 100,
611                outcome: TurnOutcomeKind::Completed {
612                    stop_reason: "EndTurn".into(),
613                },
614                usage: scope.map(|scope| TokenUsage {
615                    provider_details: Some(Box::new(mj_core::usage::ProviderTurnUsage {
616                        cost: Some(mj_core::usage::ProviderTurnCost::from_usd_ticks(
617                            88_767_200, false,
618                        )),
619                        model_calls: Some(2),
620                        api_duration_ms: Some(1700),
621                        elapsed_ms: Some(2377),
622                        model_usage: BTreeMap::new(),
623                    })),
624                    scope,
625                    total_tokens: 30,
626                    input_tokens: 20,
627                    output_tokens: 10,
628                    thought_tokens: None,
629                    cached_read_tokens: Some(0),
630                    cached_write_tokens: None,
631                }),
632            };
633            turns.push(turn);
634        }
635        for _ in 0..2 {
636            apply_projection_page_to(&path, "usage", |page| {
637                for turn in &turns {
638                    let prior = if turn.completed_ordinal == 1 {
639                        mj_core::relay::RELAY_EVENT_GENESIS_DIGEST.into()
640                    } else {
641                        format!("{:064x}", turn.completed_ordinal - 1)
642                    };
643                    page.apply(
644                        turn.completed_ordinal,
645                        &prior,
646                        &format!("{:064x}", turn.completed_ordinal),
647                        &MaterializedSessionMutation {
648                            last_turn_outcome: Some(turn.clone()),
649                            provider_cost: Some(ProviderCost {
650                                amount: 1.25,
651                                currency: "USD".into(),
652                                observed_at_ms: 100,
653                            }),
654                            ..Default::default()
655                        },
656                    )?;
657                }
658                Ok(())
659            })?;
660        }
661        let first = load_session_usage_from(&path, "usage", 0, 2)?.unwrap();
662        assert_eq!(first.turns.len(), 2);
663        let details = first.turns[0]
664            .usage
665            .as_ref()
666            .unwrap()
667            .provider_details
668            .as_ref()
669            .unwrap();
670        assert_eq!(details.cost.as_ref().unwrap().usd, "0.0088767200");
671        assert_eq!(details.elapsed_ms, Some(2377));
672        assert_eq!(
673            first.turns[0].diagnostic.as_ref().unwrap().http_status,
674            Some(403)
675        );
676        assert_eq!(first.provider_session_cost.as_ref().unwrap().amount, 1.25);
677        assert_eq!(first.coverage.recorded_turns, 5);
678        assert_eq!(first.coverage.full_turn_reports, 2);
679        assert_eq!(first.coverage.last_request_reports, 1);
680        assert_eq!(first.coverage.unspecified_reports, 1);
681        assert_eq!(first.coverage.missing_reports, 1);
682        // Only the two whole-turn reports are summed: the last-request and
683        // unspecified turns stay out of the totals.
684        assert_eq!(first.totals["total_tokens"].tokens, 60);
685        assert_eq!(first.totals["total_tokens"].reported_turns, 2);
686        assert_eq!(first.totals["cached_read_tokens"].tokens, 0);
687        assert!(!first.totals.contains_key("thought_tokens"));
688        let next = load_session_usage_from(&path, "usage", first.next_after_seq, 2)?.unwrap();
689        assert_eq!(next.turns.len(), 2);
690        assert_eq!(next.latest_seq, 5);
691        assert_eq!(next.totals, first.totals);
692        let last = load_session_usage_from(&path, "usage", next.next_after_seq, 2)?.unwrap();
693        assert_eq!(last.turns.len(), 1);
694        assert_eq!(
695            last.turns[0].usage.as_ref().unwrap().scope,
696            UsageScope::Unspecified
697        );
698        assert_eq!(last.next_after_seq, last.latest_seq);
699        assert_eq!(last.totals, first.totals);
700        Ok(())
701    }
702}