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 } => {
198 state.pending_call = Some(PendingCall::Tool {
199 seq: *seq,
200 tool: tool.clone(),
201 input: input.clone(),
202 effect: *effect,
203 idempotency_key: idempotency_key.clone(),
204 });
205 // The write rule: an uncompleted write intent is
206 // needs-reconciliation the moment it is the log's last word,
207 // and every prefix ending here is exactly that log.
208 state.status = match effect {
209 Effect::Write => RunStatus::NeedsReconciliation,
210 Effect::Read | Effect::Idempotent => RunStatus::AwaitingTool,
211 };
212 }
213 Event::ToolCallCompleted { .. } => {
214 state.pending_call = None;
215 state.status = RunStatus::Running;
216 }
217 // Deterministic-context observations change no run status; they
218 // only exist so replay can hand the same values back.
219 Event::NowObserved { .. } | Event::RandomObserved { .. } => {}
220 Event::Suspended {
221 reason,
222 input_schema,
223 } => {
224 state.status = RunStatus::Suspended {
225 reason: reason.clone(),
226 input_schema: input_schema.clone(),
227 };
228 }
229 Event::Resumed { .. } => {
230 state.status = RunStatus::Running;
231 }
232 Event::BudgetExceeded { budget, observed } => {
233 state.status = RunStatus::BudgetExceeded {
234 budget: *budget,
235 observed: *observed,
236 };
237 }
238 Event::RunCompleted { output } => {
239 state.status = RunStatus::Completed {
240 output: output.clone(),
241 };
242 }
243 Event::RunFailed { error } => {
244 state.status = RunStatus::Failed {
245 error: error.clone(),
246 };
247 }
248 // An operator-appended terminal. Abandonment gets its own resting
249 // status, never `Failed`: the two are different facts and read
250 // differently downstream. The `pending_call` is left as the log's
251 // last dangling intent implied (kept through terminal events, like
252 // `Failed`), so an abandoned needs-reconciliation run still surfaces
253 // the write; `unresolved_write` is the durable, recorded copy of
254 // that evidence carried on the status itself.
255 Event::RunAbandoned {
256 reason,
257 unresolved_write,
258 } => {
259 state.status = RunStatus::Abandoned {
260 reason: reason.clone(),
261 unresolved_write: unresolved_write.clone(),
262 };
263 }
264 // A graph run's head. It stands where an agent run's `RunStarted`
265 // does: the run is now under way, so the status becomes `Running`.
266 // No new `RunStatus` variant is minted for graph runs: the whole
267 // point of this fold's graph handling is that a graph run reads
268 // through the same agent-run status vocabulary. Between its
269 // recorded steps it is `Running`; a dangling model or tool call
270 // inside one of its agent or tool nodes still arrives as an
271 // ordinary `ModelCallRequested`/`ToolCallRequested`, so the arms
272 // above already carry it to `AwaitingModel`, `AwaitingTool`, or
273 // `NeedsReconciliation` with no graph-specific code. `usage`
274 // accumulates across every node's model calls; `pending_call`
275 // surfaces whichever node's call is dangling. The per-node picture
276 // (which node is current, which branch fired, map fan-out) is a
277 // separate projection, `crate::graph_state`, deliberately kept out
278 // of this run-level status.
279 Event::GraphRunStarted { .. } => {
280 state.status = RunStatus::Running;
281 }
282 // The graph node/branch/map/fold markers narrate the walk for the
283 // per-node projection; at the run level they are structural notes
284 // that change no status, exactly like the context observations
285 // above. A graph run sits at `Running` across all of them (the
286 // real call boundaries are the model/tool intents they bracket).
287 Event::NodeEntered { .. }
288 | Event::NodeExited { .. }
289 | Event::NodeSkipped { .. }
290 | Event::BranchTaken { .. }
291 | Event::MapFannedOut { .. }
292 | Event::MapIterationStarted { .. }
293 | Event::MapIterationJoined { .. }
294 | Event::FoldIterationStarted { .. }
295 | Event::FoldIterationJoined { .. }
296 | Event::FoldConverged { .. } => {}
297 }
298 }
299 state
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::event::TokenUsage;
306 use crate::id::RunId;
307 use time::macros::datetime;
308 use uuid::Uuid;
309
310 fn log(events: Vec<Event>) -> Vec<EventEnvelope> {
311 let run_id =
312 RunId::from_uuid(Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap());
313 events
314 .into_iter()
315 .enumerate()
316 .map(|(i, event)| {
317 EventEnvelope::new(
318 run_id,
319 SequenceNumber::new(i as u64),
320 datetime!(2026-07-09 12:00:00 UTC),
321 event,
322 )
323 })
324 .collect()
325 }
326
327 fn started() -> Event {
328 Event::RunStarted {
329 agent_def_hash: "sha256:agent".into(),
330 input: serde_json::json!({"topic": "otters"}),
331 labels: None,
332 }
333 }
334
335 /// The empty prefix is a state too: nothing recorded, next position 0.
336 #[test]
337 fn empty_log_derives_not_started() {
338 let state = derive_state(&[]);
339 assert_eq!(state.status, RunStatus::NotStarted);
340 assert_eq!(state.next_seq, SequenceNumber::new(0));
341 assert_eq!(state.usage, TokenTotals::default());
342 assert_eq!(state.pending_call, None);
343 }
344
345 /// A log ending between steps derives to running.
346 #[test]
347 fn run_started_derives_running() {
348 let state = derive_state(&log(vec![started()]));
349 assert_eq!(state.status, RunStatus::Running);
350 assert_eq!(state.next_seq, SequenceNumber::new(1));
351 }
352
353 /// A dangling model intent derives to awaiting-model with the pending
354 /// call carrying what a re-issue needs.
355 #[test]
356 fn dangling_model_intent_derives_awaiting_model() {
357 let state = derive_state(&log(vec![
358 started(),
359 Event::ModelCallRequested {
360 seq: SequenceNumber::new(1),
361 request_hash: "sha256:req".into(),
362 request_body: None,
363 },
364 ]));
365 assert_eq!(state.status, RunStatus::AwaitingModel);
366 assert_eq!(
367 state.pending_call,
368 Some(PendingCall::Model {
369 seq: SequenceNumber::new(1),
370 request_hash: "sha256:req".into(),
371 })
372 );
373 }
374
375 /// A dangling read intent derives to awaiting-tool: reads re-execute
376 /// freely, so the state is retryable.
377 #[test]
378 fn dangling_read_intent_derives_awaiting_tool() {
379 let state = derive_state(&log(vec![
380 started(),
381 Event::ToolCallRequested {
382 seq: SequenceNumber::new(1),
383 tool: "search".into(),
384 input: serde_json::json!({"q": "otters"}),
385 effect: Effect::Read,
386 idempotency_key: None,
387 performed_by: None,
388 },
389 ]));
390 assert_eq!(state.status, RunStatus::AwaitingTool);
391 }
392
393 /// A dangling idempotent intent is retryable and surfaces its recorded
394 /// idempotency key, so the retry collapses at the provider.
395 #[test]
396 fn dangling_idempotent_intent_surfaces_recorded_key() {
397 let state = derive_state(&log(vec![
398 started(),
399 Event::ToolCallRequested {
400 seq: SequenceNumber::new(1),
401 tool: "store".into(),
402 input: serde_json::json!({"doc": 1}),
403 effect: Effect::Idempotent,
404 idempotency_key: Some("key-7".into()),
405 performed_by: None,
406 },
407 ]));
408 assert_eq!(state.status, RunStatus::AwaitingTool);
409 match state.pending_call {
410 Some(PendingCall::Tool {
411 idempotency_key, ..
412 }) => assert_eq!(idempotency_key.as_deref(), Some("key-7")),
413 other => panic!("expected pending tool call, got {other:?}"),
414 }
415 }
416
417 /// The write rule: a dangling write intent derives to
418 /// needs-reconciliation, never to anything retryable.
419 #[test]
420 fn dangling_write_intent_derives_needs_reconciliation() {
421 let state = derive_state(&log(vec![
422 started(),
423 Event::ToolCallRequested {
424 seq: SequenceNumber::new(1),
425 tool: "create_ticket".into(),
426 input: serde_json::json!({"title": "bug"}),
427 effect: Effect::Write,
428 idempotency_key: None,
429 performed_by: None,
430 },
431 ]));
432 assert_eq!(state.status, RunStatus::NeedsReconciliation);
433 assert!(matches!(
434 state.pending_call,
435 Some(PendingCall::Tool {
436 effect: Effect::Write,
437 ..
438 })
439 ));
440 }
441
442 /// A completed write intent is history, not a hazard.
443 #[test]
444 fn completed_write_derives_running() {
445 let state = derive_state(&log(vec![
446 started(),
447 Event::ToolCallRequested {
448 seq: SequenceNumber::new(1),
449 tool: "create_ticket".into(),
450 input: serde_json::json!({"title": "bug"}),
451 effect: Effect::Write,
452 idempotency_key: None,
453 performed_by: None,
454 },
455 Event::ToolCallCompleted {
456 seq: SequenceNumber::new(1),
457 output: serde_json::json!({"id": "TICKET-1"}),
458 deduplicated_from: None,
459 },
460 ]));
461 assert_eq!(state.status, RunStatus::Running);
462 assert_eq!(state.pending_call, None);
463 }
464
465 /// A log ending at a suspension derives to suspended, carrying what a
466 /// resume needs.
467 #[test]
468 fn suspension_without_resume_derives_suspended() {
469 let schema = serde_json::json!({"type": "object"});
470 let state = derive_state(&log(vec![
471 started(),
472 Event::Suspended {
473 reason: "awaiting approval".into(),
474 input_schema: schema.clone(),
475 },
476 ]));
477 assert_eq!(
478 state.status,
479 RunStatus::Suspended {
480 reason: "awaiting approval".into(),
481 input_schema: schema,
482 }
483 );
484 }
485
486 /// A recorded resume puts the run back in running.
487 #[test]
488 fn resume_derives_running() {
489 let state = derive_state(&log(vec![
490 started(),
491 Event::Suspended {
492 reason: "awaiting approval".into(),
493 input_schema: serde_json::json!({"type": "object"}),
494 },
495 Event::Resumed {
496 input: serde_json::json!({"approved": true}),
497 },
498 ]));
499 assert_eq!(state.status, RunStatus::Running);
500 }
501
502 /// A log ending at a budget crossing derives to budget-exceeded, parked
503 /// rather than dead.
504 #[test]
505 fn budget_crossing_derives_budget_exceeded() {
506 let budget = Budget {
507 kind: crate::event::BudgetKind::CostUsd,
508 limit: 2.0,
509 };
510 let state = derive_state(&log(vec![
511 started(),
512 Event::BudgetExceeded {
513 budget,
514 observed: 2.5,
515 },
516 ]));
517 assert_eq!(
518 state.status,
519 RunStatus::BudgetExceeded {
520 budget,
521 observed: 2.5,
522 }
523 );
524 }
525
526 /// Terminal events derive to completed and failed, carrying their
527 /// recorded payloads.
528 #[test]
529 fn terminal_events_derive_terminal_statuses() {
530 let completed = derive_state(&log(vec![
531 started(),
532 Event::RunCompleted {
533 output: serde_json::json!({"summary": "done"}),
534 },
535 ]));
536 assert_eq!(
537 completed.status,
538 RunStatus::Completed {
539 output: serde_json::json!({"summary": "done"}),
540 }
541 );
542
543 let failed = derive_state(&log(vec![
544 started(),
545 Event::RunFailed {
546 error: "provider timeout".into(),
547 },
548 ]));
549 assert_eq!(
550 failed.status,
551 RunStatus::Failed {
552 error: "provider timeout".into(),
553 }
554 );
555 }
556
557 /// An abandonment derives to the abandoned terminal, carrying its recorded
558 /// reason. A bare abandonment (no dangling write) carries no
559 /// unresolved-write evidence.
560 #[test]
561 fn abandonment_derives_abandoned() {
562 let state = derive_state(&log(vec![
563 started(),
564 Event::RunAbandoned {
565 reason: Some("husk is dead forever".into()),
566 unresolved_write: None,
567 },
568 ]));
569 assert_eq!(
570 state.status,
571 RunStatus::Abandoned {
572 reason: Some("husk is dead forever".into()),
573 unresolved_write: None,
574 }
575 );
576 }
577
578 /// Abandoning a needs-reconciliation run derives to the abandoned terminal
579 /// carrying the unresolved-write evidence, and the dangling write is still
580 /// surfaced through `pending_call` (kept through the terminal event), so the
581 /// abandoned state never claims the write question was answered.
582 #[test]
583 fn abandonment_of_needs_reconciliation_records_unresolved_write() {
584 let state = derive_state(&log(vec![
585 started(),
586 Event::ToolCallRequested {
587 seq: SequenceNumber::new(1),
588 tool: "create_ticket".into(),
589 input: serde_json::json!({"title": "bug"}),
590 effect: Effect::Write,
591 idempotency_key: None,
592 performed_by: None,
593 },
594 Event::RunAbandoned {
595 reason: None,
596 unresolved_write: Some(UnresolvedWrite {
597 seq: SequenceNumber::new(1),
598 tool: "create_ticket".into(),
599 }),
600 },
601 ]));
602 assert_eq!(
603 state.status,
604 RunStatus::Abandoned {
605 reason: None,
606 unresolved_write: Some(UnresolvedWrite {
607 seq: SequenceNumber::new(1),
608 tool: "create_ticket".into(),
609 }),
610 }
611 );
612 assert!(matches!(
613 state.pending_call,
614 Some(PendingCall::Tool {
615 effect: Effect::Write,
616 ..
617 })
618 ));
619 }
620
621 /// Context observations leave the status untouched.
622 #[test]
623 fn context_events_do_not_change_status() {
624 let state = derive_state(&log(vec![
625 started(),
626 Event::NowObserved {
627 now: datetime!(2026-07-09 12:00:00.123456789 UTC),
628 },
629 Event::RandomObserved { value: u64::MAX },
630 ]));
631 assert_eq!(state.status, RunStatus::Running);
632 assert_eq!(state.next_seq, SequenceNumber::new(3));
633 }
634
635 fn graph_started() -> Event {
636 Event::GraphRunStarted {
637 graph_hash: "sha256:graph".into(),
638 input: serde_json::json!({"topic": "otters"}),
639 labels: None,
640 forked_from: None,
641 }
642 }
643
644 /// A graph run's head derives to running, exactly as an agent run's does:
645 /// no new status is minted for graph runs.
646 #[test]
647 fn graph_run_started_derives_running() {
648 let state = derive_state(&log(vec![graph_started()]));
649 assert_eq!(state.status, RunStatus::Running);
650 assert_eq!(state.next_seq, SequenceNumber::new(1));
651 }
652
653 /// The graph node/branch/map markers change no run status: a graph run
654 /// reads as running across them, and `next_seq` still advances.
655 #[test]
656 fn graph_markers_do_not_change_status() {
657 let state = derive_state(&log(vec![
658 graph_started(),
659 Event::NodeEntered {
660 node: "research".into(),
661 },
662 Event::BranchTaken {
663 node: "gate".into(),
664 case: "approved".into(),
665 },
666 Event::MapFannedOut {
667 node: "fanout".into(),
668 items: serde_json::json!([1, 2]),
669 },
670 Event::MapIterationStarted {
671 node: "fanout".into(),
672 index: 0,
673 child_run: "sha256:child".into(),
674 },
675 Event::MapIterationJoined {
676 node: "fanout".into(),
677 index: 0,
678 },
679 Event::NodeExited {
680 node: "research".into(),
681 },
682 Event::NodeSkipped {
683 node: "publish".into(),
684 reason: "unreached".into(),
685 },
686 ]));
687 assert_eq!(state.status, RunStatus::Running);
688 assert_eq!(state.next_seq, SequenceNumber::new(8));
689 assert_eq!(state.pending_call, None);
690 }
691
692 /// A dangling model call inside a graph run's agent node surfaces through
693 /// the same `AwaitingModel` status as an agent run: the graph markers add
694 /// no new call-boundary status, they only bracket the real intents.
695 #[test]
696 fn dangling_model_call_inside_a_node_derives_awaiting_model() {
697 let state = derive_state(&log(vec![
698 graph_started(),
699 Event::NodeEntered {
700 node: "research".into(),
701 },
702 Event::ModelCallRequested {
703 seq: SequenceNumber::new(2),
704 request_hash: "sha256:req".into(),
705 request_body: None,
706 },
707 ]));
708 assert_eq!(state.status, RunStatus::AwaitingModel);
709 assert_eq!(
710 state.pending_call,
711 Some(PendingCall::Model {
712 seq: SequenceNumber::new(2),
713 request_hash: "sha256:req".into(),
714 })
715 );
716 }
717
718 /// Usage accumulates across every completed model call, widened to u64.
719 #[test]
720 fn usage_accumulates_across_model_calls() {
721 let state = derive_state(&log(vec![
722 started(),
723 Event::ModelCallRequested {
724 seq: SequenceNumber::new(1),
725 request_hash: "sha256:a".into(),
726 request_body: None,
727 },
728 Event::ModelCallCompleted {
729 seq: SequenceNumber::new(1),
730 response: serde_json::json!({"text": "one"}),
731 usage: TokenUsage {
732 input_tokens: 100,
733 output_tokens: 40,
734 },
735 },
736 Event::ModelCallRequested {
737 seq: SequenceNumber::new(3),
738 request_hash: "sha256:b".into(),
739 request_body: None,
740 },
741 Event::ModelCallCompleted {
742 seq: SequenceNumber::new(3),
743 response: serde_json::json!({"text": "two"}),
744 usage: TokenUsage {
745 input_tokens: u32::MAX,
746 output_tokens: 2,
747 },
748 },
749 ]));
750 assert_eq!(
751 state.usage,
752 TokenTotals {
753 input_tokens: 100 + u64::from(u32::MAX),
754 output_tokens: 42,
755 }
756 );
757 }
758}