Skip to main content

recursive/
session.rs

1//! Session files for production pause/resume.
2//!
3//! A `SessionFile` captures enough agent state to resume a run that
4//! terminated abnormally (budget exceeded, stuck, transcript limit).
5//! It stores the goal, model, provider, a hash of the tool registry,
6//! the steps consumed so far, and the full transcript.
7//!
8//! Sessions are written alongside transcripts when `--session-out` is
9//! set and the finish reason is non-success. They live under
10//! `<workspace>/.recursive/sessions/` by convention.
11
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15use crate::llm::ToolSpec;
16use crate::message::Message;
17
18/// Current schema version for session files.
19/// Increment when the format changes in a breaking way.
20const SESSION_SCHEMA_VERSION: u32 = 1;
21
22/// A saved session that can be resumed.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SessionFile {
25    pub schema_version: u32,
26    /// The original goal string.
27    pub goal: String,
28    /// Model name (e.g. "gpt-4o-mini").
29    pub model: String,
30    /// Provider identifier (e.g. "openai").
31    pub provider: String,
32    /// BLAKE3 hash of the tool registry specs at the time of save.
33    /// Used to detect tool changes that would make the session invalid.
34    pub tool_registry_hash: String,
35    /// Number of steps already consumed.
36    pub steps_consumed: usize,
37    /// The full transcript so far (system prompt + user goal + all exchanges).
38    pub transcript: Vec<Message>,
39}
40
41impl SessionFile {
42    /// Create a new session file from the agent's current state.
43    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    /// Write the session to a JSON file at `path`.
64    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    /// Read a session from a JSON file at `path`.
74    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    /// Validate that the tool registry hash matches the current tool specs.
81    /// Returns `Ok(())` if they match, or an error describing the mismatch.
82    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    /// Return the transcript messages.
96    pub fn messages(&self) -> &[Message] {
97        &self.transcript
98    }
99
100    /// Consume self and return the transcript.
101    pub fn into_transcript(self) -> Vec<Message> {
102        self.transcript
103    }
104}
105
106/// Compute a BLAKE3 hash of the tool registry specs.
107///
108/// The hash is computed over a deterministic JSON representation of the
109/// specs, sorted by tool name. This ensures that the same set of tools
110/// always produces the same hash, regardless of registration order.
111fn 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
127/// Default session output path for a given workspace.
128/// Returns `<workspace>/.recursive/sessions/<timestamp>-<goal-prefix>.json`.
129pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
130    let ts = chrono_lite_now();
131    // Sanitise the goal prefix for use in a filename
132    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
148/// List all session files in a workspace's session directory.
149pub 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
166// Tiny RFC3339-ish timestamp without pulling in `chrono`. Format:
167// "YYYY-MM-DDTHH:MM:SSZ" using UTC.
168fn 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        // Same specs should validate
267        assert!(session.validate_tool_registry(&tool_specs).is_ok());
268
269        // Different specs should fail
270        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        // No sessions dir yet
284        let sessions = list_sessions(ws).unwrap();
285        assert!(sessions.is_empty());
286
287        // Create a session file
288        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        // The transcript should be preserved exactly
324        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        // into_transcript should give back the messages
330        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        // Verify the assistant message with tool_calls is preserved
377        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        // Verify the tool result message
395        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        // Validate against a completely different set of tools
448        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        // Goal with spaces, slashes, unicode, and other special chars
469        let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
470        let path = default_session_path(ws, goal);
471
472        // Extract just the filename (without the .json extension)
473        let filename = path.file_stem().unwrap().to_str().unwrap();
474
475        // The filename format is "{timestamp}-{sanitized_goal}".
476        // The timestamp is fixed-format (YYYY-MM-DDTHH:MM:SSZ) and contains colons.
477        // We verify the goal-derived suffix: strip the timestamp prefix
478        // (everything up to and including the "Z-" separator).
479        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        // The goal suffix should contain only alphanumeric (unicode-aware), underscore, or dash
485        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        // Spaces, slashes, #, —, ™ should all be stripped
496        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        // The path should still be under the sessions directory
503        assert!(path.starts_with(ws.join(".recursive").join("sessions")));
504        // And should have .json extension
505        assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
506    }
507}