1use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15use crate::llm::ToolSpec;
16use crate::message::Message;
17
18const SESSION_SCHEMA_VERSION: u32 = 1;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SessionFile {
25 pub schema_version: u32,
26 pub goal: String,
28 pub model: String,
30 pub provider: String,
32 pub tool_registry_hash: String,
35 pub steps_consumed: usize,
37 pub transcript: Vec<Message>,
39}
40
41impl SessionFile {
42 pub fn new(
44 goal: String,
45 model: String,
46 provider: String,
47 tool_specs: &[ToolSpec],
48 steps_consumed: usize,
49 transcript: Vec<Message>,
50 ) -> Self {
51 let tool_registry_hash = hash_tool_specs(tool_specs);
52 Self {
53 schema_version: SESSION_SCHEMA_VERSION,
54 goal,
55 model,
56 provider,
57 tool_registry_hash,
58 steps_consumed,
59 transcript,
60 }
61 }
62
63 pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
65 let json = serde_json::to_string_pretty(self)
66 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
67 if let Some(parent) = path.parent() {
68 std::fs::create_dir_all(parent)?;
69 }
70 std::fs::write(path, json)
71 }
72
73 pub fn read_from(path: &Path) -> std::io::Result<Self> {
75 let bytes = std::fs::read(path)?;
76 serde_json::from_slice(&bytes)
77 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
78 }
79
80 pub fn validate_tool_registry(&self, current_specs: &[ToolSpec]) -> Result<(), String> {
83 let current_hash = hash_tool_specs(current_specs);
84 if self.tool_registry_hash == current_hash {
85 Ok(())
86 } else {
87 Err(format!(
88 "tool registry hash mismatch: session has '{}', current is '{}'. \
89 Tools have changed since the session was saved; cannot resume.",
90 self.tool_registry_hash, current_hash
91 ))
92 }
93 }
94
95 pub fn messages(&self) -> &[Message] {
97 &self.transcript
98 }
99
100 pub fn into_transcript(self) -> Vec<Message> {
102 self.transcript
103 }
104}
105
106fn hash_tool_specs(specs: &[ToolSpec]) -> String {
112 use std::collections::BTreeMap;
113
114 let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
115 for spec in specs {
116 let value = serde_json::json!({
117 "description": spec.description,
118 "parameters": spec.parameters,
119 });
120 map.insert(spec.name.clone(), value);
121 }
122 let canonical = serde_json::to_string(&map).unwrap_or_default();
123 let hash = blake3::hash(canonical.as_bytes());
124 hash.to_hex().to_string()
125}
126
127pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
130 let ts = chrono_lite_now();
131 let prefix: String = goal
133 .chars()
134 .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
135 .take(40)
136 .collect();
137 let prefix = if prefix.is_empty() {
138 "unnamed".to_string()
139 } else {
140 prefix
141 };
142 workspace
143 .join(".recursive")
144 .join("sessions")
145 .join(format!("{}-{}.json", ts, prefix))
146}
147
148pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
150 let dir = workspace.join(".recursive").join("sessions");
151 if !dir.is_dir() {
152 return Ok(Vec::new());
153 }
154 let mut sessions = Vec::new();
155 for entry in std::fs::read_dir(&dir)? {
156 let entry = entry?;
157 let path = entry.path();
158 if path.extension().and_then(|e| e.to_str()) == Some("json") {
159 sessions.push(path);
160 }
161 }
162 sessions.sort();
163 Ok(sessions)
164}
165
166fn chrono_lite_now() -> String {
169 use std::time::{SystemTime, UNIX_EPOCH};
170 let secs = SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .map(|d| d.as_secs())
173 .unwrap_or(0);
174 let day = secs / 86_400;
175 let sec_of_day = secs % 86_400;
176 let (h, m, s) = (sec_of_day / 3600, (sec_of_day / 60) % 60, sec_of_day % 60);
177 let (y, mo, d) = epoch_day_to_ymd(day as i64);
178 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
179}
180
181fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
182 let z = z + 719_468;
183 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
184 let doe = (z - era * 146_097) as u64;
185 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
186 let y = yoe as i64 + era * 400;
187 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
188 let mp = (5 * doy + 2) / 153;
189 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
190 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
191 let y = if m <= 2 { y + 1 } else { y };
192 (y, m, d)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::message::{Message, Role};
199
200 #[test]
201 fn session_round_trip() {
202 let goal = "fix the bug".to_string();
203 let model = "gpt-4o-mini".to_string();
204 let provider = "openai".to_string();
205 let tool_specs = vec![
206 ToolSpec {
207 name: "read_file".into(),
208 description: "Read a file".into(),
209 parameters: serde_json::json!({"type":"object"}),
210 },
211 ToolSpec {
212 name: "write_file".into(),
213 description: "Write a file".into(),
214 parameters: serde_json::json!({"type":"object"}),
215 },
216 ];
217 let transcript = vec![
218 Message::system("You are a helpful assistant.".to_string()),
219 Message::user("fix the bug".to_string()),
220 Message::assistant("Let me look at the code.".to_string()),
221 ];
222
223 let session = SessionFile::new(
224 goal.clone(),
225 model.clone(),
226 provider.clone(),
227 &tool_specs,
228 2,
229 transcript.clone(),
230 );
231
232 let tmp = tempfile::NamedTempFile::new().unwrap();
233 session.write_to(tmp.path()).unwrap();
234
235 let restored = SessionFile::read_from(tmp.path()).unwrap();
236 assert_eq!(restored.schema_version, SESSION_SCHEMA_VERSION);
237 assert_eq!(restored.goal, goal);
238 assert_eq!(restored.model, model);
239 assert_eq!(restored.provider, provider);
240 assert_eq!(restored.steps_consumed, 2);
241 assert_eq!(restored.transcript.len(), 3);
242 assert_eq!(
243 restored.transcript[0].content,
244 "You are a helpful assistant."
245 );
246 assert_eq!(restored.transcript[1].content, "fix the bug");
247 assert_eq!(restored.transcript[2].content, "Let me look at the code.");
248 }
249
250 #[test]
251 fn resume_validates_tool_registry_hash() {
252 let tool_specs = vec![ToolSpec {
253 name: "read_file".into(),
254 description: "Read a file".into(),
255 parameters: serde_json::json!({"type":"object"}),
256 }];
257 let session = SessionFile::new(
258 "test".into(),
259 "model".into(),
260 "provider".into(),
261 &tool_specs,
262 0,
263 vec![],
264 );
265
266 assert!(session.validate_tool_registry(&tool_specs).is_ok());
268
269 let different_specs = vec![ToolSpec {
271 name: "write_file".into(),
272 description: "Write a file".into(),
273 parameters: serde_json::json!({"type":"object"}),
274 }];
275 assert!(session.validate_tool_registry(&different_specs).is_err());
276 }
277
278 #[test]
279 fn session_list_finds_files_in_workspace() {
280 let tmp = tempfile::tempdir().unwrap();
281 let ws = tmp.path();
282
283 let sessions = list_sessions(ws).unwrap();
285 assert!(sessions.is_empty());
286
287 let session = SessionFile::new(
289 "test".into(),
290 "model".into(),
291 "provider".into(),
292 &[],
293 0,
294 vec![],
295 );
296 let path = default_session_path(ws, "test");
297 session.write_to(&path).unwrap();
298
299 let sessions = list_sessions(ws).unwrap();
300 assert_eq!(sessions.len(), 1);
301 assert_eq!(
302 sessions[0].extension().and_then(|e| e.to_str()),
303 Some("json")
304 );
305 }
306
307 #[test]
308 fn resume_continues_from_seeded_transcript() {
309 let transcript = vec![
310 Message::system("sys".to_string()),
311 Message::user("original goal".to_string()),
312 Message::assistant("partial work".to_string()),
313 ];
314 let session = SessionFile::new(
315 "original goal".into(),
316 "model".into(),
317 "provider".into(),
318 &[],
319 1,
320 transcript.clone(),
321 );
322
323 assert_eq!(session.messages().len(), 3);
325 assert_eq!(session.messages()[0].content, "sys");
326 assert_eq!(session.messages()[1].content, "original goal");
327 assert_eq!(session.messages()[2].content, "partial work");
328
329 let restored = session.into_transcript();
331 assert_eq!(restored.len(), 3);
332 }
333
334 #[test]
335 fn round_trip_with_tool_calls() {
336 use crate::llm::ToolCall;
337
338 let tool_calls = vec![
339 ToolCall {
340 id: "call_001".to_string(),
341 name: "read_file".to_string(),
342 arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
343 },
344 ToolCall {
345 id: "call_002".to_string(),
346 name: "write_file".to_string(),
347 arguments: serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"}),
348 },
349 ];
350
351 let transcript = vec![
352 Message::system("You are an agent.".to_string()),
353 Message::user("refactor the code".to_string()),
354 Message::assistant_with_tool_calls(
355 "I'll read the file first.".to_string(),
356 tool_calls.clone(),
357 ),
358 Message::tool_result("call_001", "fn main() { println!(\"hello\"); }"),
359 ];
360
361 let session = SessionFile::new(
362 "refactor".into(),
363 "gpt-4o".into(),
364 "openai".into(),
365 &[],
366 3,
367 transcript,
368 );
369
370 let tmp = tempfile::NamedTempFile::new().unwrap();
371 session.write_to(tmp.path()).unwrap();
372
373 let restored = SessionFile::read_from(tmp.path()).unwrap();
374 assert_eq!(restored.transcript.len(), 4);
375
376 let assistant_msg = &restored.transcript[2];
378 assert_eq!(assistant_msg.role, Role::Assistant);
379 assert_eq!(assistant_msg.content, "I'll read the file first.");
380 assert_eq!(assistant_msg.tool_calls.len(), 2);
381 assert_eq!(assistant_msg.tool_calls[0].id, "call_001");
382 assert_eq!(assistant_msg.tool_calls[0].name, "read_file");
383 assert_eq!(
384 assistant_msg.tool_calls[0].arguments,
385 serde_json::json!({"path": "/tmp/foo.rs"})
386 );
387 assert_eq!(assistant_msg.tool_calls[1].id, "call_002");
388 assert_eq!(assistant_msg.tool_calls[1].name, "write_file");
389 assert_eq!(
390 assistant_msg.tool_calls[1].arguments,
391 serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"})
392 );
393
394 let tool_msg = &restored.transcript[3];
396 assert_eq!(tool_msg.role, Role::Tool);
397 assert_eq!(tool_msg.tool_call_id, Some("call_001".to_string()));
398 assert_eq!(tool_msg.content, "fn main() { println!(\"hello\"); }");
399 }
400
401 #[test]
402 fn read_from_nonexistent_file() {
403 let tmp = tempfile::tempdir().unwrap();
404 let bogus_path = tmp.path().join("does_not_exist.json");
405
406 let result = SessionFile::read_from(&bogus_path);
407 assert!(result.is_err());
408 let err = result.unwrap_err();
409 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
410 }
411
412 #[test]
413 fn read_from_corrupt_json() {
414 let tmp = tempfile::NamedTempFile::new().unwrap();
415 std::fs::write(tmp.path(), "this is not valid json {{{garbage").unwrap();
416
417 let result = SessionFile::read_from(tmp.path());
418 assert!(result.is_err());
419 let err = result.unwrap_err();
420 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
421 }
422
423 #[test]
424 fn validate_tool_registry_mismatch() {
425 let original_specs = vec![
426 ToolSpec {
427 name: "read_file".into(),
428 description: "Read a file".into(),
429 parameters: serde_json::json!({"type":"object"}),
430 },
431 ToolSpec {
432 name: "write_file".into(),
433 description: "Write a file".into(),
434 parameters: serde_json::json!({"type":"object"}),
435 },
436 ];
437
438 let session = SessionFile::new(
439 "test".into(),
440 "model".into(),
441 "provider".into(),
442 &original_specs,
443 0,
444 vec![],
445 );
446
447 let different_specs = vec![ToolSpec {
449 name: "execute_command".into(),
450 description: "Run a shell command".into(),
451 parameters: serde_json::json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
452 }];
453
454 let result = session.validate_tool_registry(&different_specs);
455 assert!(result.is_err());
456 let err_msg = result.unwrap_err();
457 assert!(
458 err_msg.contains("mismatch"),
459 "Expected error to contain 'mismatch', got: {err_msg}"
460 );
461 }
462
463 #[test]
464 fn default_session_path_sanitizes_special_chars() {
465 let tmp = tempfile::tempdir().unwrap();
466 let ws = tmp.path();
467
468 let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
470 let path = default_session_path(ws, goal);
471
472 let filename = path.file_stem().unwrap().to_str().unwrap();
474
475 let goal_suffix = filename
480 .find("Z-")
481 .map(|i| &filename[i + 2..])
482 .expect("filename should contain Z- separator between timestamp and goal");
483
484 for ch in goal_suffix.chars() {
486 assert!(
487 ch.is_alphanumeric() || ch == '_' || ch == '-',
488 "Unexpected character '{}' (U+{:04X}) in goal suffix: {}",
489 ch,
490 ch as u32,
491 goal_suffix
492 );
493 }
494
495 assert!(!goal_suffix.contains(' '));
497 assert!(!goal_suffix.contains('/'));
498 assert!(!goal_suffix.contains('#'));
499 assert!(!goal_suffix.contains('™'));
500 assert!(!goal_suffix.contains('—'));
501
502 assert!(path.starts_with(ws.join(".recursive").join("sessions")));
504 assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
506 }
507}