1use crate::{EventPayload, ModelFinishReason, ModelSelection, RunId, RunSelection, StoredEvent};
3
4#[derive(Clone, Debug, Default, PartialEq)]
5pub struct SessionModelState {
6 pub selection: Option<RunSelection>,
7 pub successful_fallback: bool,
8 run: Option<RunId>,
9 chain: Vec<ModelSelection>,
10}
11
12impl SessionModelState {
13 pub fn from_events(events: &[StoredEvent]) -> Self {
14 let mut state = Self::default();
15 for event in events {
16 state.apply(event.run_id, &event.payload);
17 }
18 state
19 }
20
21 pub fn apply(&mut self, run: Option<RunId>, event: &EventPayload) {
22 match event {
23 EventPayload::SessionCreated {
24 creation_selection, ..
25 } => {
26 self.selection = Some(creation_selection.clone());
27 }
28 EventPayload::RunStarted {
29 selection,
30 selected_suffix,
31 ..
32 } => {
33 self.successful_fallback &= self.selection.as_ref() == Some(selection);
34 self.selection = Some(selection.clone());
35 self.chain = selected_suffix
36 .iter()
37 .map(|binding| binding.selection.clone())
38 .collect();
39 self.run = run;
40 }
41 EventPayload::ModelTurnCommitted {
42 resolved_model,
43 turn,
44 ..
45 } if run.is_some() && run == self.run && successful_finish(&turn.finish_reason) => {
46 if self
49 .chain
50 .iter()
51 .position(|model| *model == resolved_model.selection)
52 .is_some_and(|index| index > 0)
53 && let Some(selection) = &mut self.selection
54 {
55 selection.model = resolved_model.selection.clone();
56 self.successful_fallback = true;
57 }
58 }
59 _ => {}
60 }
61 }
62
63 pub fn continuation(
66 &self,
67 requested: &RunSelection,
68 chain: &[ModelSelection],
69 reset: bool,
70 ) -> RunSelection {
71 if !reset
72 && self.successful_fallback
73 && let Some(selected) = &self.selection
74 && selected.agent == requested.agent
75 && selected.preset == requested.preset
76 && let Some(requested_index) = chain.iter().position(|model| *model == requested.model)
77 && let Some(selected_index) = chain.iter().position(|model| *model == selected.model)
78 && requested_index < selected_index
79 {
80 return selected.clone();
81 }
82 requested.clone()
83 }
84}
85
86fn successful_finish(reason: &ModelFinishReason) -> bool {
87 match reason {
88 ModelFinishReason::Stop | ModelFinishReason::ToolCalls | ModelFinishReason::Length => true,
90 ModelFinishReason::ContentFilter | ModelFinishReason::Cancelled | ModelFinishReason::Error
91 | ModelFinishReason::Aborted | ModelFinishReason::Timeout | ModelFinishReason::Refused
92 | ModelFinishReason::Unknown | ModelFinishReason::Other(_) => false,
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn selection(model: &str) -> RunSelection {
102 RunSelection {
103 agent: "primary".parse().unwrap(),
104 preset: None,
105 model: ModelSelection {
106 model: format!("test/{model}").parse().unwrap(),
107 variant: None,
108 },
109 }
110 }
111
112 fn committed(model: &RunSelection, finish_reason: ModelFinishReason) -> EventPayload {
113 EventPayload::ModelTurnCommitted {
114 attempt_id: crate::AttemptId::new_v7(),
115 model_turn_seq: 2,
116 input_through_seq: 1,
117 resolved_model: crate::ResolvedModelRef {
118 selection: model.model.clone(),
119 provider_id: "test".parse().unwrap(),
120 model_id: model.model.model.model_id().clone(),
121 adapter_id: crate::AdaptorId::OpenaiCompatible,
122 selection_fingerprint: crate::Sha256Digest::of_bytes(b"fixture"),
123 },
124 turn: crate::PersistedModelTurn {
125 content: vec![],
126 provider_options: Default::default(),
127 finish_reason,
128 usage: Default::default(),
129 response_metadata: Default::default(),
130 provider_metadata: Default::default(),
131 native_replay: None,
132 },
133 warnings: vec![],
134 }
135 }
136
137 #[test]
138 fn only_successful_finish_reasons_advance_fallback_and_later_failure_keeps_progress() {
139 let a = selection("a");
140 let b = selection("b");
141 let c = selection("c");
142 let run = Some(RunId::new_v7());
143 let initial = SessionModelState {
144 selection: Some(a.clone()),
145 run,
146 chain: vec![a.model.clone(), b.model.clone(), c.model.clone()],
147 successful_fallback: false,
148 };
149 for (reason, success) in [
150 (ModelFinishReason::Stop, true),
151 (ModelFinishReason::ToolCalls, true),
152 (ModelFinishReason::Length, true),
153 (ModelFinishReason::Cancelled, false),
154 (ModelFinishReason::Aborted, false),
155 (ModelFinishReason::Error, false),
156 (ModelFinishReason::Timeout, false),
157 (ModelFinishReason::ContentFilter, false),
158 (ModelFinishReason::Refused, false),
159 (ModelFinishReason::Unknown, false),
160 (ModelFinishReason::Other("vendor-specific".into()), false),
161 ] {
162 let mut state = initial.clone();
163 state.apply(run, &committed(&b, reason.clone()));
164 assert_eq!(
165 state.selection,
166 Some(if success { b.clone() } else { a.clone() }),
167 "{reason:?}"
168 );
169 assert_eq!(state.successful_fallback, success, "{reason:?}");
170 if !success {
171 state.apply(run, &committed(&b, ModelFinishReason::ToolCalls));
172 state.apply(run, &committed(&c, reason));
173 state.apply(
174 run,
175 &EventPayload::RunFailed {
176 error: crate::SafeErrorMessage::new("later run failure").unwrap(),
177 model_error: None,
178 resolved_model: None,
179 },
180 );
181 state.apply(run, &EventPayload::RunCancelled { reason: None });
182 assert_eq!(state.selection, Some(b.clone()));
183 assert!(state.successful_fallback);
184 assert_eq!(state.continuation(&a, &initial.chain, false), b);
185 }
186 }
187 }
188
189 #[test]
190 fn continuation_uses_exact_identity_and_respects_scope_and_reset() {
191 let a = selection("a");
192 let b = selection("b");
193 let c = selection("c");
194 let state = SessionModelState {
195 selection: Some(b.clone()),
196 successful_fallback: true,
197 ..Default::default()
198 };
199 let chain = vec![a.model.clone(), b.model.clone(), c.model.clone()];
200 assert_eq!(state.continuation(&a, &chain, false), b);
201 assert_eq!(state.continuation(&a, &chain, true), a);
202 assert_eq!(state.continuation(&c, &chain, false), c);
203 assert_eq!(
204 state.continuation(
205 &a,
206 &[a.model.clone(), c.model.clone(), b.model.clone()],
207 false
208 ),
209 b
210 );
211 assert_eq!(
212 state.continuation(&a, &[a.model.clone(), c.model], false),
213 a
214 );
215 let mut other = a.clone();
216 other.agent = "other".parse().unwrap();
217 assert_eq!(state.continuation(&other, &chain, false), other);
218 other = a.clone();
219 other.preset = Some("python".into());
220 assert_eq!(state.continuation(&other, &chain, false), other);
221 let mut variant_b = b;
222 variant_b.model.variant = Some("fast".parse().unwrap());
223 let state = SessionModelState {
224 selection: Some(variant_b.clone()),
225 successful_fallback: true,
226 ..Default::default()
227 };
228 assert_eq!(state.continuation(&a, &chain, false), a);
229 assert_eq!(
230 state.continuation(&a, &[a.model.clone(), variant_b.model.clone()], false),
231 variant_b
232 );
233 }
234
235 #[test]
236 fn reset_flag_is_optional_and_round_trips_without_changing_events() {
237 let wire = serde_json::json!({"session_id":crate::SessionId::new_v7(),"client_run_id":"test","selection":selection("a"),"input":"hello"});
238 let mut params: crate::RunStartParams = serde_json::from_value(wire.clone()).unwrap();
239 assert!(!params.reset_fallback);
240 assert_eq!(serde_json::to_value(¶ms).unwrap(), wire);
241 params.reset_fallback = true;
242 assert_eq!(
243 serde_json::to_value(params).unwrap()["reset_fallback"],
244 true
245 );
246 }
247}