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
160fn 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 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 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 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}