1use crate::constants::versions::SESSION_STATE_VERSION;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use super::{ReasoningEffort, Runner};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27pub struct PhaseSettingsSnapshot {
28 pub runner: Runner,
30 pub model: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub reasoning_effort: Option<ReasoningEffort>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct SessionState {
41 pub version: u32,
43
44 pub session_id: String,
46
47 pub task_id: String,
49
50 pub run_started_at: String,
52
53 pub last_updated_at: String,
55
56 pub iterations_planned: u8,
58
59 pub iterations_completed: u8,
61
62 pub current_phase: u8,
64
65 pub runner: Runner,
67
68 pub model: String,
70
71 pub tasks_completed_in_loop: u32,
73
74 pub max_tasks: u32,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub git_head_commit: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub phase1_settings: Option<PhaseSettingsSnapshot>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
89 pub phase2_settings: Option<PhaseSettingsSnapshot>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
94 pub phase3_settings: Option<PhaseSettingsSnapshot>,
95}
96
97impl SessionState {
98 #[allow(clippy::too_many_arguments)]
100 pub fn new(
101 session_id: String,
102 task_id: String,
103 run_started_at: String,
104 iterations_planned: u8,
105 runner: Runner,
106 model: String,
107 max_tasks: u32,
108 git_head_commit: Option<String>,
109 phase_settings: Option<(
110 PhaseSettingsSnapshot,
111 PhaseSettingsSnapshot,
112 PhaseSettingsSnapshot,
113 )>,
114 ) -> Self {
115 let (phase1_settings, phase2_settings, phase3_settings) = phase_settings
116 .map(|(p1, p2, p3)| (Some(p1), Some(p2), Some(p3)))
117 .unwrap_or((None, None, None));
118
119 Self {
120 version: SESSION_STATE_VERSION,
121 session_id,
122 task_id,
123 run_started_at: run_started_at.clone(),
124 last_updated_at: run_started_at,
125 iterations_planned,
126 iterations_completed: 0,
127 current_phase: 1,
128 runner,
129 model,
130 tasks_completed_in_loop: 0,
131 max_tasks,
132 git_head_commit,
133 phase1_settings,
134 phase2_settings,
135 phase3_settings,
136 }
137 }
138
139 pub fn mark_iteration_complete(&mut self, completed_at: String) {
141 self.iterations_completed += 1;
142 self.last_updated_at = completed_at;
143 }
144
145 pub fn set_phase(&mut self, phase: u8, updated_at: String) {
147 self.current_phase = phase;
148 self.last_updated_at = updated_at;
149 }
150
151 pub fn mark_task_complete(&mut self, updated_at: String) {
153 self.tasks_completed_in_loop += 1;
154 self.last_updated_at = updated_at;
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn test_session() -> SessionState {
163 SessionState::new(
164 "test-session-id".to_string(),
165 "RQ-0001".to_string(),
166 "2026-01-30T00:00:00.000000000Z".to_string(),
167 2,
168 Runner::Claude,
169 "sonnet".to_string(),
170 10,
171 Some("abc123".to_string()),
172 None, )
174 }
175
176 #[test]
177 fn session_new_sets_defaults() {
178 let session = test_session();
179
180 assert_eq!(session.version, SESSION_STATE_VERSION);
181 assert_eq!(session.session_id, "test-session-id");
182 assert_eq!(session.task_id, "RQ-0001");
183 assert_eq!(session.iterations_planned, 2);
184 assert_eq!(session.iterations_completed, 0);
185 assert_eq!(session.current_phase, 1);
186 assert_eq!(session.tasks_completed_in_loop, 0);
187 assert_eq!(session.max_tasks, 10);
188 assert_eq!(session.git_head_commit, Some("abc123".to_string()));
189 }
190
191 #[test]
192 fn session_mark_iteration_complete_increments_count() {
193 let mut session = test_session();
194
195 session.mark_iteration_complete("2026-01-30T00:01:00.000000000Z".to_string());
196
197 assert_eq!(session.iterations_completed, 1);
198 assert_eq!(session.last_updated_at, "2026-01-30T00:01:00.000000000Z");
199 }
200
201 #[test]
202 fn session_set_phase_updates_phase() {
203 let mut session = test_session();
204
205 session.set_phase(2, "2026-01-30T00:02:00.000000000Z".to_string());
206
207 assert_eq!(session.current_phase, 2);
208 assert_eq!(session.last_updated_at, "2026-01-30T00:02:00.000000000Z");
209 }
210
211 #[test]
212 fn session_mark_task_complete_increments_count() {
213 let mut session = test_session();
214
215 session.mark_task_complete("2026-01-30T00:03:00.000000000Z".to_string());
216
217 assert_eq!(session.tasks_completed_in_loop, 1);
218 assert_eq!(session.last_updated_at, "2026-01-30T00:03:00.000000000Z");
219 }
220
221 #[test]
222 fn session_serialization_roundtrip() {
223 let session = test_session();
224
225 let json = serde_json::to_string(&session).expect("serialize");
226 let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
227
228 assert_eq!(deserialized.session_id, session.session_id);
229 assert_eq!(deserialized.task_id, session.task_id);
230 assert_eq!(deserialized.iterations_planned, session.iterations_planned);
231 assert_eq!(deserialized.runner, session.runner);
232 assert_eq!(deserialized.model, session.model);
233 }
234
235 #[test]
236 fn session_deserialization_ignores_optional_git_commit_when_none() {
237 let session = SessionState::new(
238 "test-id".to_string(),
239 "RQ-0001".to_string(),
240 "2026-01-30T00:00:00.000000000Z".to_string(),
241 1,
242 Runner::Claude,
243 "sonnet".to_string(),
244 0,
245 None,
246 None, );
248
249 let json = serde_json::to_string(&session).expect("serialize");
250 assert!(!json.contains("git_head_commit"));
251
252 let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
253 assert_eq!(deserialized.git_head_commit, None);
254 }
255
256 #[test]
257 fn session_new_with_phase_settings() {
258 let phase_settings = (
259 PhaseSettingsSnapshot {
260 runner: Runner::Claude,
261 model: "sonnet".to_string(),
262 reasoning_effort: None,
263 },
264 PhaseSettingsSnapshot {
265 runner: Runner::Codex,
266 model: "o3-mini".to_string(),
267 reasoning_effort: Some(ReasoningEffort::High),
268 },
269 PhaseSettingsSnapshot {
270 runner: Runner::Claude,
271 model: "haiku".to_string(),
272 reasoning_effort: None,
273 },
274 );
275
276 let session = SessionState::new(
277 "test-id".to_string(),
278 "RQ-0001".to_string(),
279 "2026-01-30T00:00:00.000000000Z".to_string(),
280 1,
281 Runner::Claude,
282 "sonnet".to_string(),
283 0,
284 None,
285 Some(phase_settings),
286 );
287
288 assert!(session.phase1_settings.is_some());
289 assert!(session.phase2_settings.is_some());
290 assert!(session.phase3_settings.is_some());
291
292 let p1 = session.phase1_settings.unwrap();
293 assert_eq!(p1.runner, Runner::Claude);
294 assert_eq!(p1.model, "sonnet");
295 assert_eq!(p1.reasoning_effort, None);
296
297 let p2 = session.phase2_settings.unwrap();
298 assert_eq!(p2.runner, Runner::Codex);
299 assert_eq!(p2.model, "o3-mini");
300 assert_eq!(p2.reasoning_effort, Some(ReasoningEffort::High));
301
302 let p3 = session.phase3_settings.unwrap();
303 assert_eq!(p3.runner, Runner::Claude);
304 assert_eq!(p3.model, "haiku");
305 assert_eq!(p3.reasoning_effort, None);
306 }
307
308 #[test]
309 fn session_serialization_with_phase_settings() {
310 let phase_settings = (
311 PhaseSettingsSnapshot {
312 runner: Runner::Claude,
313 model: "sonnet".to_string(),
314 reasoning_effort: None,
315 },
316 PhaseSettingsSnapshot {
317 runner: Runner::Codex,
318 model: "o3-mini".to_string(),
319 reasoning_effort: Some(ReasoningEffort::Medium),
320 },
321 PhaseSettingsSnapshot {
322 runner: Runner::Claude,
323 model: "haiku".to_string(),
324 reasoning_effort: None,
325 },
326 );
327
328 let session = SessionState::new(
329 "test-id".to_string(),
330 "RQ-0001".to_string(),
331 "2026-01-30T00:00:00.000000000Z".to_string(),
332 1,
333 Runner::Claude,
334 "sonnet".to_string(),
335 0,
336 None,
337 Some(phase_settings),
338 );
339
340 let json = serde_json::to_string(&session).expect("serialize");
341 let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
342
343 assert_eq!(deserialized.phase1_settings, session.phase1_settings);
344 assert_eq!(deserialized.phase2_settings, session.phase2_settings);
345 assert_eq!(deserialized.phase3_settings, session.phase3_settings);
346 }
347
348 #[test]
349 fn session_deserialization_backward_compatible_without_phase_settings() {
350 let json = r#"{
353 "version": 1,
354 "session_id": "test-id",
355 "task_id": "RQ-0001",
356 "run_started_at": "2026-01-30T00:00:00.000000000Z",
357 "last_updated_at": "2026-01-30T00:00:00.000000000Z",
358 "iterations_planned": 1,
359 "iterations_completed": 0,
360 "current_phase": 1,
361 "runner": "claude",
362 "model": "sonnet",
363 "tasks_completed_in_loop": 0,
364 "max_tasks": 0
365 }"#;
366
367 let session: SessionState = serde_json::from_str(json).expect("deserialize old format");
368 assert_eq!(session.phase1_settings, None);
369 assert_eq!(session.phase2_settings, None);
370 assert_eq!(session.phase3_settings, None);
371 }
372
373 #[test]
374 fn session_serialization_skips_none_phase_settings() {
375 let session = SessionState::new(
376 "test-id".to_string(),
377 "RQ-0001".to_string(),
378 "2026-01-30T00:00:00.000000000Z".to_string(),
379 1,
380 Runner::Claude,
381 "sonnet".to_string(),
382 0,
383 None,
384 None, );
386
387 let json = serde_json::to_string(&session).expect("serialize");
388 assert!(!json.contains("phase1_settings"));
390 assert!(!json.contains("phase2_settings"));
391 assert!(!json.contains("phase3_settings"));
392 }
393}