Skip to main content

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}