use crate::constants::versions::SESSION_STATE_VERSION;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::{ReasoningEffort, Runner};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PhaseSettingsSnapshot {
pub runner: Runner,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SessionState {
pub version: u32,
pub session_id: String,
pub task_id: String,
pub run_started_at: String,
pub last_updated_at: String,
pub iterations_planned: u8,
pub iterations_completed: u8,
pub current_phase: u8,
pub runner: Runner,
pub model: String,
pub tasks_completed_in_loop: u32,
pub max_tasks: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_head_commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase1_settings: Option<PhaseSettingsSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase2_settings: Option<PhaseSettingsSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase3_settings: Option<PhaseSettingsSnapshot>,
}
impl SessionState {
#[allow(clippy::too_many_arguments)]
pub fn new(
session_id: String,
task_id: String,
run_started_at: String,
iterations_planned: u8,
runner: Runner,
model: String,
max_tasks: u32,
git_head_commit: Option<String>,
phase_settings: Option<(
PhaseSettingsSnapshot,
PhaseSettingsSnapshot,
PhaseSettingsSnapshot,
)>,
) -> Self {
let (phase1_settings, phase2_settings, phase3_settings) = phase_settings
.map(|(p1, p2, p3)| (Some(p1), Some(p2), Some(p3)))
.unwrap_or((None, None, None));
Self {
version: SESSION_STATE_VERSION,
session_id,
task_id,
run_started_at: run_started_at.clone(),
last_updated_at: run_started_at,
iterations_planned,
iterations_completed: 0,
current_phase: 1,
runner,
model,
tasks_completed_in_loop: 0,
max_tasks,
git_head_commit,
phase1_settings,
phase2_settings,
phase3_settings,
}
}
pub fn mark_iteration_complete(&mut self, completed_at: String) {
self.iterations_completed += 1;
self.last_updated_at = completed_at;
}
pub fn set_phase(&mut self, phase: u8, updated_at: String) {
self.current_phase = phase;
self.last_updated_at = updated_at;
}
pub fn mark_task_complete(&mut self, updated_at: String) {
self.tasks_completed_in_loop += 1;
self.last_updated_at = updated_at;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_session() -> SessionState {
SessionState::new(
"test-session-id".to_string(),
"RQ-0001".to_string(),
"2026-01-30T00:00:00.000000000Z".to_string(),
2,
Runner::Claude,
"sonnet".to_string(),
10,
Some("abc123".to_string()),
None, )
}
#[test]
fn session_new_sets_defaults() {
let session = test_session();
assert_eq!(session.version, SESSION_STATE_VERSION);
assert_eq!(session.session_id, "test-session-id");
assert_eq!(session.task_id, "RQ-0001");
assert_eq!(session.iterations_planned, 2);
assert_eq!(session.iterations_completed, 0);
assert_eq!(session.current_phase, 1);
assert_eq!(session.tasks_completed_in_loop, 0);
assert_eq!(session.max_tasks, 10);
assert_eq!(session.git_head_commit, Some("abc123".to_string()));
}
#[test]
fn session_mark_iteration_complete_increments_count() {
let mut session = test_session();
session.mark_iteration_complete("2026-01-30T00:01:00.000000000Z".to_string());
assert_eq!(session.iterations_completed, 1);
assert_eq!(session.last_updated_at, "2026-01-30T00:01:00.000000000Z");
}
#[test]
fn session_set_phase_updates_phase() {
let mut session = test_session();
session.set_phase(2, "2026-01-30T00:02:00.000000000Z".to_string());
assert_eq!(session.current_phase, 2);
assert_eq!(session.last_updated_at, "2026-01-30T00:02:00.000000000Z");
}
#[test]
fn session_mark_task_complete_increments_count() {
let mut session = test_session();
session.mark_task_complete("2026-01-30T00:03:00.000000000Z".to_string());
assert_eq!(session.tasks_completed_in_loop, 1);
assert_eq!(session.last_updated_at, "2026-01-30T00:03:00.000000000Z");
}
#[test]
fn session_serialization_roundtrip() {
let session = test_session();
let json = serde_json::to_string(&session).expect("serialize");
let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.session_id, session.session_id);
assert_eq!(deserialized.task_id, session.task_id);
assert_eq!(deserialized.iterations_planned, session.iterations_planned);
assert_eq!(deserialized.runner, session.runner);
assert_eq!(deserialized.model, session.model);
}
#[test]
fn session_deserialization_ignores_optional_git_commit_when_none() {
let session = SessionState::new(
"test-id".to_string(),
"RQ-0001".to_string(),
"2026-01-30T00:00:00.000000000Z".to_string(),
1,
Runner::Claude,
"sonnet".to_string(),
0,
None,
None, );
let json = serde_json::to_string(&session).expect("serialize");
assert!(!json.contains("git_head_commit"));
let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.git_head_commit, None);
}
#[test]
fn session_new_with_phase_settings() {
let phase_settings = (
PhaseSettingsSnapshot {
runner: Runner::Claude,
model: "sonnet".to_string(),
reasoning_effort: None,
},
PhaseSettingsSnapshot {
runner: Runner::Codex,
model: "o3-mini".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
},
PhaseSettingsSnapshot {
runner: Runner::Claude,
model: "haiku".to_string(),
reasoning_effort: None,
},
);
let session = SessionState::new(
"test-id".to_string(),
"RQ-0001".to_string(),
"2026-01-30T00:00:00.000000000Z".to_string(),
1,
Runner::Claude,
"sonnet".to_string(),
0,
None,
Some(phase_settings),
);
assert!(session.phase1_settings.is_some());
assert!(session.phase2_settings.is_some());
assert!(session.phase3_settings.is_some());
let p1 = session.phase1_settings.unwrap();
assert_eq!(p1.runner, Runner::Claude);
assert_eq!(p1.model, "sonnet");
assert_eq!(p1.reasoning_effort, None);
let p2 = session.phase2_settings.unwrap();
assert_eq!(p2.runner, Runner::Codex);
assert_eq!(p2.model, "o3-mini");
assert_eq!(p2.reasoning_effort, Some(ReasoningEffort::High));
let p3 = session.phase3_settings.unwrap();
assert_eq!(p3.runner, Runner::Claude);
assert_eq!(p3.model, "haiku");
assert_eq!(p3.reasoning_effort, None);
}
#[test]
fn session_serialization_with_phase_settings() {
let phase_settings = (
PhaseSettingsSnapshot {
runner: Runner::Claude,
model: "sonnet".to_string(),
reasoning_effort: None,
},
PhaseSettingsSnapshot {
runner: Runner::Codex,
model: "o3-mini".to_string(),
reasoning_effort: Some(ReasoningEffort::Medium),
},
PhaseSettingsSnapshot {
runner: Runner::Claude,
model: "haiku".to_string(),
reasoning_effort: None,
},
);
let session = SessionState::new(
"test-id".to_string(),
"RQ-0001".to_string(),
"2026-01-30T00:00:00.000000000Z".to_string(),
1,
Runner::Claude,
"sonnet".to_string(),
0,
None,
Some(phase_settings),
);
let json = serde_json::to_string(&session).expect("serialize");
let deserialized: SessionState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.phase1_settings, session.phase1_settings);
assert_eq!(deserialized.phase2_settings, session.phase2_settings);
assert_eq!(deserialized.phase3_settings, session.phase3_settings);
}
#[test]
fn session_deserialization_backward_compatible_without_phase_settings() {
let json = r#"{
"version": 1,
"session_id": "test-id",
"task_id": "RQ-0001",
"run_started_at": "2026-01-30T00:00:00.000000000Z",
"last_updated_at": "2026-01-30T00:00:00.000000000Z",
"iterations_planned": 1,
"iterations_completed": 0,
"current_phase": 1,
"runner": "claude",
"model": "sonnet",
"tasks_completed_in_loop": 0,
"max_tasks": 0
}"#;
let session: SessionState = serde_json::from_str(json).expect("deserialize old format");
assert_eq!(session.phase1_settings, None);
assert_eq!(session.phase2_settings, None);
assert_eq!(session.phase3_settings, None);
}
#[test]
fn session_serialization_skips_none_phase_settings() {
let session = SessionState::new(
"test-id".to_string(),
"RQ-0001".to_string(),
"2026-01-30T00:00:00.000000000Z".to_string(),
1,
Runner::Claude,
"sonnet".to_string(),
0,
None,
None, );
let json = serde_json::to_string(&session).expect("serialize");
assert!(!json.contains("phase1_settings"));
assert!(!json.contains("phase2_settings"));
assert!(!json.contains("phase3_settings"));
}
}