gestura_core_sessions/agent_sessions/
legacy_gui_migration.rs1use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18
19use super::{AgentSession, AgentSessionStore, SessionState};
20
21fn gestura_data_dir() -> PathBuf {
23 super::gestura_home_dir().join(".gestura")
24}
25
26pub fn legacy_gui_sessions_file_path() -> PathBuf {
28 gestura_data_dir().join("gui_sessions.json")
29}
30
31pub fn sanitize_session_llm_override(
41 session_id: &str,
42 state: &mut SessionState,
43 global_llm_provider: &str,
44 is_model_compatible: impl Fn(&str, &str) -> bool,
45) -> bool {
46 let Some(ref mut llm_cfg) = state.llm_config else {
47 return false;
48 };
49
50 let mut repaired = false;
51 if let Some(ref model) = llm_cfg.model {
52 let effective_provider = llm_cfg.provider.as_deref().unwrap_or(global_llm_provider);
53 if !is_model_compatible(effective_provider, model) {
54 tracing::warn!(
55 session_id = %session_id,
56 provider = %effective_provider,
57 model = %model,
58 "Clearing incompatible persisted session LLM model override"
59 );
60 llm_cfg.model = None;
61 repaired = true;
62 }
63 }
64
65 if llm_cfg.provider.is_none() && llm_cfg.model.is_none() {
67 state.llm_config = None;
68 }
69
70 repaired
71}
72
73pub fn migrate_legacy_gui_sessions_to_core<S: AgentSessionStore>(
80 store: &S,
81 global_llm_provider: &str,
82 is_model_compatible: impl Fn(&str, &str) -> bool,
83) -> Vec<AgentSession> {
84 migrate_legacy_gui_sessions_to_core_at_path(
85 store,
86 global_llm_provider,
87 &legacy_gui_sessions_file_path(),
88 &is_model_compatible,
89 )
90}
91
92pub fn migrate_legacy_gui_sessions_to_core_at_path<S: AgentSessionStore>(
96 store: &S,
97 global_llm_provider: &str,
98 path: &Path,
99 is_model_compatible: &dyn Fn(&str, &str) -> bool,
100) -> Vec<AgentSession> {
101 if !path.exists() {
102 return Vec::new();
103 }
104
105 let mut migrated = Vec::new();
106 match std::fs::read_to_string(path) {
107 Ok(json) => match serde_json::from_str::<LegacyPersistedSessions>(&json) {
108 Ok(persisted) => {
109 for mut session in persisted.sessions {
110 session.is_open = false;
112 session.window_label = None;
113 session.message_count = session.state.messages.len();
114
115 let _ = sanitize_session_llm_override(
117 &session.id,
118 &mut session.state,
119 global_llm_provider,
120 is_model_compatible,
121 );
122
123 let model = session
124 .state
125 .llm_config
126 .as_ref()
127 .and_then(|cfg| cfg.model.clone());
128 let core_session = AgentSession {
129 id: session.id,
130 title: session.title,
131 created_at: session.created_at,
132 last_active: session.last_active,
133 model,
134 state: session.state,
135 };
136
137 match store.save(&core_session) {
138 Ok(()) => migrated.push(core_session),
139 Err(e) => tracing::warn!(
140 session_id = %core_session.id,
141 error = %e,
142 "Failed to migrate legacy GUI session to core store"
143 ),
144 }
145 }
146
147 if let Err(e) = std::fs::remove_file(path) {
149 tracing::debug!(
150 error = %e,
151 path = %path.display(),
152 "Failed to remove legacy sessions file"
153 );
154 }
155 }
156 Err(e) => {
157 tracing::warn!(
158 error = %e,
159 path = %path.display(),
160 "Failed to parse legacy GUI sessions file"
161 );
162 }
163 },
164 Err(e) => {
165 tracing::warn!(
166 error = %e,
167 path = %path.display(),
168 "Failed to read legacy GUI sessions file"
169 );
170 }
171 }
172
173 migrated
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178#[serde(default)]
179struct LegacyPersistedSessions {
180 sessions: Vec<LegacyGuiAgentSession>,
181 version: u32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188#[serde(default)]
189struct LegacyGuiAgentSession {
190 id: String,
191 title: String,
192 created_at: DateTime<Utc>,
193 last_active: DateTime<Utc>,
194 is_open: bool,
195 window_label: Option<String>,
196 message_count: usize,
197 #[serde(default)]
198 state: SessionState,
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::agent_sessions::FileAgentSessionStore;
205
206 fn always_incompatible(_provider: &str, _model: &str) -> bool {
208 false
209 }
210
211 fn always_compatible(_provider: &str, _model: &str) -> bool {
213 true
214 }
215
216 #[test]
217 fn sanitize_clears_incompatible_model_override_and_prunes_empty_container() {
218 let mut state = SessionState {
219 llm_config: Some(crate::agent_sessions::SessionLlmConfig {
220 provider: Some("openai".to_string()),
221 model: Some("claude-3-5-sonnet-20241022".to_string()),
222 }),
223 ..Default::default()
224 };
225
226 let repaired =
227 sanitize_session_llm_override("s1", &mut state, "openai", always_incompatible);
228 assert!(repaired);
229 assert_eq!(
230 state.llm_config.as_ref().unwrap().provider.as_deref(),
231 Some("openai")
232 );
233 assert_eq!(state.llm_config.as_ref().unwrap().model, None);
234
235 state.llm_config = Some(crate::agent_sessions::SessionLlmConfig {
237 provider: None,
238 model: Some("gpt-4o".to_string()),
239 });
240 let repaired =
241 sanitize_session_llm_override("s2", &mut state, "anthropic", always_incompatible);
242 assert!(repaired);
243 assert!(state.llm_config.is_none());
244 }
245
246 #[test]
247 fn migrate_returns_empty_when_legacy_file_missing() {
248 let temp = tempfile::tempdir().unwrap();
249 let store = FileAgentSessionStore::new(temp.path().join("store"));
250 let missing = temp.path().join("gui_sessions.json");
251
252 let migrated = migrate_legacy_gui_sessions_to_core_at_path(
253 &store,
254 "openai",
255 &missing,
256 &always_compatible,
257 );
258 assert!(migrated.is_empty());
259 }
260
261 #[test]
262 fn migrate_saves_sessions_and_removes_legacy_file() {
263 let temp = tempfile::tempdir().unwrap();
264 let store_dir = temp.path().join("store");
265 let store = FileAgentSessionStore::new(store_dir);
266 let legacy_path = temp.path().join("gui_sessions.json");
267
268 let legacy = LegacyPersistedSessions {
269 sessions: vec![LegacyGuiAgentSession {
270 id: "abc".to_string(),
271 title: "Hello".to_string(),
272 created_at: Utc::now(),
273 last_active: Utc::now(),
274 is_open: true,
275 window_label: Some("agent-abc".to_string()),
276 message_count: 123,
277 state: SessionState::default(),
278 }],
279 version: 1,
280 };
281
282 std::fs::write(&legacy_path, serde_json::to_string(&legacy).unwrap()).unwrap();
283
284 let migrated = migrate_legacy_gui_sessions_to_core_at_path(
285 &store,
286 "openai",
287 &legacy_path,
288 &always_compatible,
289 );
290 assert_eq!(migrated.len(), 1);
291 assert_eq!(migrated[0].id, "abc");
292 assert!(!legacy_path.exists());
293
294 let loaded = store.load("abc").unwrap();
296 assert_eq!(loaded.title, "Hello");
297 }
298}