Skip to main content

agent_teams/models/
session.rs

1//! Persisted session state for agent resume across orchestrator restarts.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8/// Persisted state for a single agent session.
9///
10/// Stored at `~/.claude/teams/{team}/sessions/{agent}.json` and used to
11/// re-spawn agents with the same configuration after an orchestrator restart.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct SessionState {
15    /// Agent name.
16    pub name: String,
17    /// Backend type string (e.g. "claude-code", "codex", "gemini-cli").
18    pub backend_type: String,
19    /// The prompt / system instruction.
20    pub prompt: String,
21    /// Model override (if any).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub model: Option<String>,
24    /// Working directory.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub cwd: Option<PathBuf>,
27    /// Max turns.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub max_turns: Option<i32>,
30    /// Allowed tools.
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub allowed_tools: Vec<String>,
33    /// Permission mode.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub permission_mode: Option<String>,
36    /// Reasoning effort.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub reasoning_effort: Option<String>,
39    /// Extra environment variables.
40    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41    pub env: HashMap<String, String>,
42    /// Memory configuration for cross-turn context injection.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub memory_config: Option<crate::memory::MemoryConfig>,
45    /// Backend-specific metadata (e.g. Codex thread ID for informational purposes).
46    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
47    pub metadata: HashMap<String, String>,
48    /// Timestamp when the session was created.
49    pub created_at: chrono::DateTime<chrono::Utc>,
50}
51
52impl SessionState {
53    /// Create a new session state from a spawn config and backend type.
54    pub fn from_config(
55        config: &crate::backend::SpawnConfig,
56        backend_type: &crate::backend::BackendType,
57    ) -> Self {
58        Self {
59            name: config.name.clone(),
60            backend_type: backend_type.to_string(),
61            prompt: config.prompt.clone(),
62            model: config.model.clone(),
63            cwd: config.cwd.clone(),
64            max_turns: config.max_turns,
65            allowed_tools: config.allowed_tools.clone(),
66            permission_mode: config.permission_mode.clone(),
67            reasoning_effort: config.reasoning_effort.clone(),
68            env: config.env.clone(),
69            memory_config: config.memory_config.clone(),
70            // Note: delegations are not persisted separately because the prompt
71            // is already augmented with delegation instructions before this is called.
72            metadata: HashMap::new(),
73            created_at: chrono::Utc::now(),
74        }
75    }
76
77    /// Convert back to a SpawnConfig for re-spawning.
78    pub fn to_spawn_config(&self) -> crate::backend::SpawnConfig {
79        crate::backend::SpawnConfig {
80            name: self.name.clone(),
81            prompt: self.prompt.clone(),
82            model: self.model.clone(),
83            cwd: self.cwd.clone(),
84            max_turns: self.max_turns,
85            allowed_tools: self.allowed_tools.clone(),
86            permission_mode: self.permission_mode.clone(),
87            reasoning_effort: self.reasoning_effort.clone(),
88            env: self.env.clone(),
89            memory_config: self.memory_config.clone(),
90            // Delegations are not restored — the prompt already contains
91            // the baked-in delegation instructions from the original spawn.
92            delegations: Vec::new(),
93        }
94    }
95
96    /// Parse the backend type string back to a BackendType enum.
97    ///
98    /// Uses `BackendType::from_str()` so this stays in sync with `Display`.
99    pub fn parse_backend_type(&self) -> Option<crate::backend::BackendType> {
100        self.backend_type.parse().ok()
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn session_state_round_trip() {
110        let state = SessionState {
111            name: "test-agent".into(),
112            backend_type: "codex".into(),
113            prompt: "You are a test assistant.".into(),
114            model: Some("gpt-4.1".into()),
115            cwd: Some(PathBuf::from("/tmp")),
116            max_turns: None,
117            allowed_tools: vec![],
118            permission_mode: None,
119            reasoning_effort: Some("medium".into()),
120            env: HashMap::new(),
121            memory_config: None,
122            metadata: {
123                let mut m = HashMap::new();
124                m.insert("thread_id".into(), "abc-123".into());
125                m
126            },
127            created_at: chrono::Utc::now(),
128        };
129
130        let json = serde_json::to_string_pretty(&state).unwrap();
131        let parsed: SessionState = serde_json::from_str(&json).unwrap();
132
133        assert_eq!(parsed.name, "test-agent");
134        assert_eq!(parsed.backend_type, "codex");
135        assert_eq!(parsed.model.as_deref(), Some("gpt-4.1"));
136        assert_eq!(parsed.metadata.get("thread_id").unwrap(), "abc-123");
137    }
138
139    #[test]
140    fn from_config_and_back() {
141        let config = crate::backend::SpawnConfig::new("reviewer", "You review code.");
142        let state = SessionState::from_config(&config, &crate::backend::BackendType::GeminiCli);
143
144        assert_eq!(state.name, "reviewer");
145        assert_eq!(state.backend_type, "gemini-cli");
146        assert_eq!(state.parse_backend_type(), Some(crate::backend::BackendType::GeminiCli));
147
148        let restored = state.to_spawn_config();
149        assert_eq!(restored.name, "reviewer");
150        assert_eq!(restored.prompt, "You review code.");
151    }
152}