salvor_replay/state.rs
1//! State derivation: a pure fold from an event log to the run state it
2//! implies.
3//!
4//! This is the projection behind `replay --dry-run` and, later, the
5//! dashboard: events in, state out, nothing executed. It is total over every
6//! prefix of a valid log, because a crash can happen at any event boundary
7//! and every prefix is therefore a state something might resume from.
8//!
9//! # Purity
10//!
11//! No IO, no clock, no randomness, no dependency on storage or executors.
12//! Like the replay cursor, this fold lives in the pure `salvor-replay` crate,
13//! which builds for wasm32 so the v0.3 browser inspector can derive a run's
14//! state from its log in-browser, from this same code.
15//!
16//! # The write rule
17//!
18//! A log whose last recorded tool intent has [`Effect::Write`] and no
19//! completion derives to [`RunStatus::NeedsReconciliation`], never to
20//! anything retryable. The write may or may not have reached the provider;
21//! the fold refuses to guess, and so must everything built on it.
22
23use serde_json::Value;
24
25use crate::effect::Effect;
26use crate::event::{Budget, Event, EventEnvelope, UnresolvedWrite};
27use crate::id::SequenceNumber;
28
29/// Token usage accumulated across every completed model call in a log.
30///
31/// Wider than the per-call [`crate::TokenUsage`] counters on purpose: a long
32/// run sums many calls, and the fold must stay total, so accumulation uses
33/// `u64` and saturating arithmetic instead of trusting the sum to fit.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub struct TokenTotals {
36 /// Total input (prompt) tokens across the run.
37 pub input_tokens: u64,
38 /// Total output (completion) tokens across the run.
39 pub output_tokens: u64,
40}
41
42/// A recorded call intent with no recorded completion.
43///
44/// Carries everything needed to act on the gap: re-issue a model call,
45/// retry an idempotent call under its recorded key, or show a human the
46/// evidence for reconciling a write.
47#[derive(Debug, Clone, PartialEq)]
48pub enum PendingCall {
49 /// A model call was requested and never completed. Safe to re-issue.
50 Model {
51 /// The log position of the intent.
52 seq: SequenceNumber,
53 /// The recorded hash of the request.
54 request_hash: String,
55 },
56 /// A tool call was requested and never completed. What may be done next
57 /// depends on `effect`; see the [`Effect`] docs for the table.
58 Tool {
59 /// The log position of the intent.
60 seq: SequenceNumber,
61 /// The tool that was called.
62 tool: String,
63 /// The recorded input.
64 input: Value,
65 /// The declared effect class.
66 effect: Effect,
67 /// The recorded idempotency key. For an idempotent retry this exact
68 /// key must be reused, so the provider collapses the attempts.
69 idempotency_key: Option<String>,
70 },
71}
72
73/// Where a run stands, as derived from its log alone.
74#[derive(Debug, Clone, PartialEq)]
75pub enum RunStatus {
76 /// The log is empty: nothing has been recorded yet.
77 NotStarted,
78 /// The run is between recorded steps and can continue.
79 Running,
80 /// A model call intent is recorded with no completion. The call can be
81 /// re-issued.
82 AwaitingModel,
83 /// A read or idempotent tool intent is recorded with no completion. The
84 /// call can be re-executed (reads freely, idempotent calls under their
85 /// recorded key).
86 AwaitingTool,
87 /// The run parked awaiting input. Carries what a resume needs: why it
88 /// parked and the schema the input must satisfy.
89 Suspended {
90 /// The recorded suspension reason.
91 reason: String,
92 /// The JSON Schema the resume input is validated against.
93 input_schema: Value,
94 },
95 /// A declared budget was crossed and the run parked. A human can raise
96 /// the limit and resume.
97 BudgetExceeded {
98 /// The budget that was crossed.
99 budget: Budget,
100 /// The observed value, in the units of the budget's kind.
101 observed: f64,
102 },
103 /// A write intent is recorded with no completion. The write may or may
104 /// not have happened; only a human may decide what happens next. Never
105 /// derived as retryable, by design.
106 NeedsReconciliation,
107 /// The run finished with this output.
108 Completed {
109 /// The recorded final output.
110 output: Value,
111 },
112 /// The run failed with this error.
113 Failed {
114 /// The recorded failure description.
115 error: String,
116 },
117 /// The run was abandoned by an operator: a terminal resting state in its
118 /// own right, distinct from [`RunStatus::Failed`]. Abandonment is a
119 /// deliberate retirement, not a failure, so it reads as its own muted
120 /// terminal everywhere downstream rather than borrowing the failure ink.
121 Abandoned {
122 /// The operator's optional note for why the run was abandoned.
123 reason: Option<String>,
124 /// The write intent left unsettled when a needs-reconciliation run was
125 /// abandoned, when there was one. Present only for a run abandoned from
126 /// [`RunStatus::NeedsReconciliation`]; the abandonment records it so the
127 /// terminal state never claims the write question was answered.
128 unresolved_write: Option<UnresolvedWrite>,
129 },
130}
131
132/// Everything a log prefix implies about a run.
133#[derive(Debug, Clone, PartialEq)]
134pub struct RunState {
135 /// Where the run stands.
136 pub status: RunStatus,
137 /// The position the next appended event will occupy.
138 pub next_seq: SequenceNumber,
139 /// Token usage accumulated over every completed model call.
140 pub usage: TokenTotals,
141 /// The dangling call intent, when one exists. `Some` whenever `status`
142 /// is [`RunStatus::AwaitingModel`], [`RunStatus::AwaitingTool`], or
143 /// [`RunStatus::NeedsReconciliation`]. Kept through a terminal event
144 /// too, so an explicitly failed run still shows the unresolved call.
145 pub pending_call: Option<PendingCall>,
146}
147
148/// Folds an event log into the [`RunState`] it implies.
149///
150/// Total over every prefix of a valid log: it never panics and never
151/// executes anything, whatever boundary the log was cut at. Later events
152/// simply overwrite what earlier ones implied, so feeding it prefixes of
153/// growing length walks the run's whole state history (which is exactly what
154/// a dashboard scrubber will do).
155#[must_use]
156pub fn derive_state(log: &[EventEnvelope]) -> RunState {
157 let mut state = RunState {
158 status: RunStatus::NotStarted,
159 next_seq: SequenceNumber::new(0),
160 usage: TokenTotals::default(),
161 pending_call: None,
162 };
163 for envelope in log {
164 state.next_seq = envelope.seq.next();
165 match &envelope.event {
166 Event::RunStarted { .. } => {
167 state.status = RunStatus::Running;
168 }
169 Event::ModelCallRequested {
170 seq, request_hash, ..
171 } => {
172 state.pending_call = Some(PendingCall::Model {
173 seq: *seq,
174 request_hash: request_hash.clone(),
175 });
176 state.status = RunStatus::AwaitingModel;
177 }
178 Event::ModelCallCompleted { usage, .. } => {
179 state.usage.input_tokens = state
180 .usage
181 .input_tokens
182 .saturating_add(u64::from(usage.input_tokens));
183 state.usage.output_tokens = state
184 .usage
185 .output_tokens
186 .saturating_add(u64::from(usage.output_tokens));
187 state.pending_call = None;
188 state.status = RunStatus::Running;
189 }
190 Event::ToolCallRequested {
191 seq,
192 tool,
193 input,
194 effect,
195 idempotency_key,
196 } => {
197 state.pending_call = Some(PendingCall::Tool {
198 seq: *seq,
199 tool: tool.clone(),
200 input: input.clone(),
201 effect: *effect,
202 idempotency_key: idempotency_key.clone(),
203 });
204 // The write rule: an uncompleted write intent is
205 // needs-reconciliation the moment it is the log's last word,
206 // and every prefix ending here is exactly that log.
207 state.status = match effect {
208 Effect::Write => RunStatus::NeedsReconciliation,
209 Effect::Read | Effect::Idempotent => RunStatus::AwaitingTool,
210 };
211 }
212 Event::ToolCallCompleted { .. } => {
213 state.pending_call = None;
214 state.status = RunStatus::Running;
215 }
216 // Deterministic-context observations change no run status; they
217 // only exist so replay can hand the same values back.
218 Event::NowObserved { .. } | Event::RandomObserved { .. } => {}
219 Event::Suspended {
220 reason,
221 input_schema,
222 } => {
223 state.status = RunStatus::Suspended {
224 reason: reason.clone(),
225 input_schema: input_schema.clone(),
226 };
227 }
228 Event::Resumed { .. } => {
229 state.status = RunStatus::Running;
230 }
231 Event::BudgetExceeded { budget, observed } => {
232 state.status = RunStatus::BudgetExceeded {
233 budget: *budget,
234 observed: *observed,
235 };
236 }
237 Event::RunCompleted { output } => {
238 state.status = RunStatus::Completed {
239 output: output.clone(),
240 };
241 }
242 Event::RunFailed { error } => {
243 state.status = RunStatus::Failed {
244 error: error.clone(),
245 };
246 }
247 // An operator-appended terminal. Abandonment gets its own resting
248 // status, never `Failed`: the two are different facts and read
249 // differently downstream. The `pending_call` is left as the log's
250 // last dangling intent implied (kept through terminal events, like
251 // `Failed`), so an abandoned needs-reconciliation run still surfaces
252 // the write; `unresolved_write` is the durable, recorded copy of
253 // that evidence carried on the status itself.
254 Event::RunAbandoned {
255 reason,
256 unresolved_write,
257 } => {
258 state.status = RunStatus::Abandoned {
259 reason: reason.clone(),
260 unresolved_write: unresolved_write.clone(),
261 };
262 }
263 // A graph run's head. It stands where an agent run's `RunStarted`
264 // does: the run is now under way, so the status becomes `Running`.
265 // No new `RunStatus` variant is minted for graph runs — the whole
266 // point of this fold's graph handling is that a graph run reads
267 // through the same agent-run status vocabulary. Between its
268 // recorded steps it is `Running`; a dangling model or tool call
269 // inside one of its agent or tool nodes still arrives as an
270 // ordinary `ModelCallRequested`/`ToolCallRequested`, so the arms
271 // above already carry it to `AwaitingModel`, `AwaitingTool`, or
272 // `NeedsReconciliation` with no graph-specific code. `usage`
273 // accumulates across every node's model calls; `pending_call`
274 // surfaces whichever node's call is dangling. The per-node picture
275 // (which node is current, which branch fired, map fan-out) is a
276 // separate projection, `crate::graph_state`, deliberately kept out
277 // of this run-level status.
278 Event::GraphRunStarted { .. } => {
279 state.status = RunStatus::Running;
280 }
281 // The graph node/branch/map/fold markers narrate the walk for the
282 // per-node projection; at the run level they are structural notes
283 // that change no status, exactly like the context observations
284 // above. A graph run sits at `Running` across all of them (the
285 // real call boundaries are the model/tool intents they bracket).
286 Event::NodeEntered { .. }
287 | Event::NodeExited { .. }
288 | Event::NodeSkipped { .. }
289 | Event::BranchTaken { .. }
290 | Event::MapFannedOut { .. }
291 | Event::MapIterationStarted { .. }
292 | Event::MapIterationJoined { .. }
293 | Event::FoldIterationStarted { .. }
294 | Event::FoldIterationJoined { .. }
295 | Event::FoldConverged { .. } => {}
296 }
297 }
298 state
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::event::TokenUsage;
305 use crate::id::RunId;
306 use time::macros::datetime;
307 use uuid::Uuid;
308
309 fn log(events: Vec<Event>) -> Vec<EventEnvelope> {
310 let run_id =
311 RunId::from_uuid(Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap());
312 events
313 .into_iter()
314 .enumerate()
315 .map(|(i, event)| {
316 EventEnvelope::new(
317 run_id,
318 SequenceNumber::new(i as u64),
319 datetime!(2026-07-09 12:00:00 UTC),
320 event,
321 )
322 })
323 .collect()
324 }
325
326 fn started() -> Event {
327 Event::RunStarted {
328 agent_def_hash: "sha256:agent".into(),
329 input: serde_json::json!({"topic": "otters"}),
330 labels: None,
331 }
332 }
333
334 /// The empty prefix is a state too: nothing recorded, next position 0.
335 #[test]
336 fn empty_log_derives_not_started() {
337 let state = derive_state(&[]);
338 assert_eq!(state.status, RunStatus::NotStarted);
339 assert_eq!(state.next_seq, SequenceNumber::new(0));
340 assert_eq!(state.usage, TokenTotals::default());
341 assert_eq!(state.pending_call, None);
342 }
343
344 /// A log ending between steps derives to running.
345 #[test]
346 fn run_started_derives_running() {
347 let state = derive_state(&log(vec![started()]));
348 assert_eq!(state.status, RunStatus::Running);
349 assert_eq!(state.next_seq, SequenceNumber::new(1));
350 }
351
352 /// A dangling model intent derives to awaiting-model with the pending
353 /// call carrying what a re-issue needs.
354 #[test]
355 fn dangling_model_intent_derives_awaiting_model() {
356 let state = derive_state(&log(vec![
357 started(),
358 Event::ModelCallRequested {
359 seq: SequenceNumber::new(1),
360 request_hash: "sha256:req".into(),
361 request_body: None,
362 },
363 ]));
364 assert_eq!(state.status, RunStatus::AwaitingModel);
365 assert_eq!(
366 state.pending_call,
367 Some(PendingCall::Model {
368 seq: SequenceNumber::new(1),
369 request_hash: "sha256:req".into(),
370 })
371 );
372 }
373
374 /// A dangling read intent derives to awaiting-tool: reads re-execute
375 /// freely, so the state is retryable.
376 #[test]
377 fn dangling_read_intent_derives_awaiting_tool() {
378 let state = derive_state(&log(vec![
379 started(),
380 Event::ToolCallRequested {
381 seq: SequenceNumber::new(1),
382 tool: "search".into(),
383 input: serde_json::json!({"q": "otters"}),
384 effect: Effect::Read,
385 idempotency_key: None,
386 },
387 ]));
388 assert_eq!(state.status, RunStatus::AwaitingTool);
389 }
390
391 /// A dangling idempotent intent is retryable and surfaces its recorded
392 /// idempotency key, so the retry collapses at the provider.
393 #[test]
394 fn dangling_idempotent_intent_surfaces_recorded_key() {
395 let state = derive_state(&log(vec![
396 started(),
397 Event::ToolCallRequested {
398 seq: SequenceNumber::new(1),
399 tool: "store".into(),
400 input: serde_json::json!({"doc": 1}),
401 effect: Effect::Idempotent,
402 idempotency_key: Some("key-7".into()),
403 },
404 ]));
405 assert_eq!(state.status, RunStatus::AwaitingTool);
406 match state.pending_call {
407 Some(PendingCall::Tool {
408 idempotency_key, ..
409 }) => assert_eq!(idempotency_key.as_deref(), Some("key-7")),
410 other => panic!("expected pending tool call, got {other:?}"),
411 }
412 }
413
414 /// The write rule: a dangling write intent derives to
415 /// needs-reconciliation, never to anything retryable.
416 #[test]
417 fn dangling_write_intent_derives_needs_reconciliation() {
418 let state = derive_state(&log(vec![
419 started(),
420 Event::ToolCallRequested {
421 seq: SequenceNumber::new(1),
422 tool: "create_ticket".into(),
423 input: serde_json::json!({"title": "bug"}),
424 effect: Effect::Write,
425 idempotency_key: None,
426 },
427 ]));
428 assert_eq!(state.status, RunStatus::NeedsReconciliation);
429 assert!(matches!(
430 state.pending_call,
431 Some(PendingCall::Tool {
432 effect: Effect::Write,
433 ..
434 })
435 ));
436 }
437
438 /// A completed write intent is history, not a hazard.
439 #[test]
440 fn completed_write_derives_running() {
441 let state = derive_state(&log(vec![
442 started(),
443 Event::ToolCallRequested {
444 seq: SequenceNumber::new(1),
445 tool: "create_ticket".into(),
446 input: serde_json::json!({"title": "bug"}),
447 effect: Effect::Write,
448 idempotency_key: None,
449 },
450 Event::ToolCallCompleted {
451 seq: SequenceNumber::new(1),
452 output: serde_json::json!({"id": "TICKET-1"}),
453 },
454 ]));
455 assert_eq!(state.status, RunStatus::Running);
456 assert_eq!(state.pending_call, None);
457 }
458
459 /// A log ending at a suspension derives to suspended, carrying what a
460 /// resume needs.
461 #[test]
462 fn suspension_without_resume_derives_suspended() {
463 let schema = serde_json::json!({"type": "object"});
464 let state = derive_state(&log(vec![
465 started(),
466 Event::Suspended {
467 reason: "awaiting approval".into(),
468 input_schema: schema.clone(),
469 },
470 ]));
471 assert_eq!(
472 state.status,
473 RunStatus::Suspended {
474 reason: "awaiting approval".into(),
475 input_schema: schema,
476 }
477 );
478 }
479
480 /// A recorded resume puts the run back in running.
481 #[test]
482 fn resume_derives_running() {
483 let state = derive_state(&log(vec![
484 started(),
485 Event::Suspended {
486 reason: "awaiting approval".into(),
487 input_schema: serde_json::json!({"type": "object"}),
488 },
489 Event::Resumed {
490 input: serde_json::json!({"approved": true}),
491 },
492 ]));
493 assert_eq!(state.status, RunStatus::Running);
494 }
495
496 /// A log ending at a budget crossing derives to budget-exceeded, parked
497 /// rather than dead.
498 #[test]
499 fn budget_crossing_derives_budget_exceeded() {
500 let budget = Budget {
501 kind: crate::event::BudgetKind::CostUsd,
502 limit: 2.0,
503 };
504 let state = derive_state(&log(vec![
505 started(),
506 Event::BudgetExceeded {
507 budget,
508 observed: 2.5,
509 },
510 ]));
511 assert_eq!(
512 state.status,
513 RunStatus::BudgetExceeded {
514 budget,
515 observed: 2.5,
516 }
517 );
518 }
519
520 /// Terminal events derive to completed and failed, carrying their
521 /// recorded payloads.
522 #[test]
523 fn terminal_events_derive_terminal_statuses() {
524 let completed = derive_state(&log(vec![
525 started(),
526 Event::RunCompleted {
527 output: serde_json::json!({"summary": "done"}),
528 },
529 ]));
530 assert_eq!(
531 completed.status,
532 RunStatus::Completed {
533 output: serde_json::json!({"summary": "done"}),
534 }
535 );
536
537 let failed = derive_state(&log(vec![
538 started(),
539 Event::RunFailed {
540 error: "provider timeout".into(),
541 },
542 ]));
543 assert_eq!(
544 failed.status,
545 RunStatus::Failed {
546 error: "provider timeout".into(),
547 }
548 );
549 }
550
551 /// An abandonment derives to the abandoned terminal, carrying its recorded
552 /// reason. A bare abandonment (no dangling write) carries no
553 /// unresolved-write evidence.
554 #[test]
555 fn abandonment_derives_abandoned() {
556 let state = derive_state(&log(vec![
557 started(),
558 Event::RunAbandoned {
559 reason: Some("husk is dead forever".into()),
560 unresolved_write: None,
561 },
562 ]));
563 assert_eq!(
564 state.status,
565 RunStatus::Abandoned {
566 reason: Some("husk is dead forever".into()),
567 unresolved_write: None,
568 }
569 );
570 }
571
572 /// Abandoning a needs-reconciliation run derives to the abandoned terminal
573 /// carrying the unresolved-write evidence, and the dangling write is still
574 /// surfaced through `pending_call` (kept through the terminal event), so the
575 /// abandoned state never claims the write question was answered.
576 #[test]
577 fn abandonment_of_needs_reconciliation_records_unresolved_write() {
578 let state = derive_state(&log(vec![
579 started(),
580 Event::ToolCallRequested {
581 seq: SequenceNumber::new(1),
582 tool: "create_ticket".into(),
583 input: serde_json::json!({"title": "bug"}),
584 effect: Effect::Write,
585 idempotency_key: None,
586 },
587 Event::RunAbandoned {
588 reason: None,
589 unresolved_write: Some(UnresolvedWrite {
590 seq: SequenceNumber::new(1),
591 tool: "create_ticket".into(),
592 }),
593 },
594 ]));
595 assert_eq!(
596 state.status,
597 RunStatus::Abandoned {
598 reason: None,
599 unresolved_write: Some(UnresolvedWrite {
600 seq: SequenceNumber::new(1),
601 tool: "create_ticket".into(),
602 }),
603 }
604 );
605 assert!(matches!(
606 state.pending_call,
607 Some(PendingCall::Tool {
608 effect: Effect::Write,
609 ..
610 })
611 ));
612 }
613
614 /// Context observations leave the status untouched.
615 #[test]
616 fn context_events_do_not_change_status() {
617 let state = derive_state(&log(vec![
618 started(),
619 Event::NowObserved {
620 now: datetime!(2026-07-09 12:00:00.123456789 UTC),
621 },
622 Event::RandomObserved { value: u64::MAX },
623 ]));
624 assert_eq!(state.status, RunStatus::Running);
625 assert_eq!(state.next_seq, SequenceNumber::new(3));
626 }
627
628 fn graph_started() -> Event {
629 Event::GraphRunStarted {
630 graph_hash: "sha256:graph".into(),
631 input: serde_json::json!({"topic": "otters"}),
632 labels: None,
633 forked_from: None,
634 }
635 }
636
637 /// A graph run's head derives to running, exactly as an agent run's does:
638 /// no new status is minted for graph runs.
639 #[test]
640 fn graph_run_started_derives_running() {
641 let state = derive_state(&log(vec![graph_started()]));
642 assert_eq!(state.status, RunStatus::Running);
643 assert_eq!(state.next_seq, SequenceNumber::new(1));
644 }
645
646 /// The graph node/branch/map markers change no run status: a graph run
647 /// reads as running across them, and `next_seq` still advances.
648 #[test]
649 fn graph_markers_do_not_change_status() {
650 let state = derive_state(&log(vec![
651 graph_started(),
652 Event::NodeEntered {
653 node: "research".into(),
654 },
655 Event::BranchTaken {
656 node: "gate".into(),
657 case: "approved".into(),
658 },
659 Event::MapFannedOut {
660 node: "fanout".into(),
661 items: serde_json::json!([1, 2]),
662 },
663 Event::MapIterationStarted {
664 node: "fanout".into(),
665 index: 0,
666 child_run: "sha256:child".into(),
667 },
668 Event::MapIterationJoined {
669 node: "fanout".into(),
670 index: 0,
671 },
672 Event::NodeExited {
673 node: "research".into(),
674 },
675 Event::NodeSkipped {
676 node: "publish".into(),
677 reason: "unreached".into(),
678 },
679 ]));
680 assert_eq!(state.status, RunStatus::Running);
681 assert_eq!(state.next_seq, SequenceNumber::new(8));
682 assert_eq!(state.pending_call, None);
683 }
684
685 /// A dangling model call inside a graph run's agent node surfaces through
686 /// the same `AwaitingModel` status as an agent run: the graph markers add
687 /// no new call-boundary status, they only bracket the real intents.
688 #[test]
689 fn dangling_model_call_inside_a_node_derives_awaiting_model() {
690 let state = derive_state(&log(vec![
691 graph_started(),
692 Event::NodeEntered {
693 node: "research".into(),
694 },
695 Event::ModelCallRequested {
696 seq: SequenceNumber::new(2),
697 request_hash: "sha256:req".into(),
698 request_body: None,
699 },
700 ]));
701 assert_eq!(state.status, RunStatus::AwaitingModel);
702 assert_eq!(
703 state.pending_call,
704 Some(PendingCall::Model {
705 seq: SequenceNumber::new(2),
706 request_hash: "sha256:req".into(),
707 })
708 );
709 }
710
711 /// Usage accumulates across every completed model call, widened to u64.
712 #[test]
713 fn usage_accumulates_across_model_calls() {
714 let state = derive_state(&log(vec![
715 started(),
716 Event::ModelCallRequested {
717 seq: SequenceNumber::new(1),
718 request_hash: "sha256:a".into(),
719 request_body: None,
720 },
721 Event::ModelCallCompleted {
722 seq: SequenceNumber::new(1),
723 response: serde_json::json!({"text": "one"}),
724 usage: TokenUsage {
725 input_tokens: 100,
726 output_tokens: 40,
727 },
728 },
729 Event::ModelCallRequested {
730 seq: SequenceNumber::new(3),
731 request_hash: "sha256:b".into(),
732 request_body: None,
733 },
734 Event::ModelCallCompleted {
735 seq: SequenceNumber::new(3),
736 response: serde_json::json!({"text": "two"}),
737 usage: TokenUsage {
738 input_tokens: u32::MAX,
739 output_tokens: 2,
740 },
741 },
742 ]));
743 assert_eq!(
744 state.usage,
745 TokenTotals {
746 input_tokens: 100 + u64::from(u32::MAX),
747 output_tokens: 42,
748 }
749 );
750 }
751}