Skip to main content

sac/sessions/
mod.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, Context, Result};
4use rusqlite::{params, OptionalExtension};
5use serde::{Deserialize, Serialize};
6
7use crate::model::{detect_backend, BackendKind, ReasoningEffort};
8use crate::sandbox::SandboxSpec;
9use crate::types::Message;
10
11mod codec;
12mod db;
13mod snapshot;
14mod summary;
15
16pub use db::{
17    archive_messages, create_session, list_sessions, load_last_session, load_session,
18    messages_json_byte_size, save_session,
19};
20pub use snapshot::{new_snapshot, refresh_snapshot};
21
22use codec::*;
23use summary::*;
24
25#[derive(Debug, Clone)]
26pub struct SessionSnapshot {
27    pub session_id: String,
28    pub cwd: PathBuf,
29    pub store_path: PathBuf,
30    pub model: String,
31    pub base_url: String,
32    pub backend: BackendKind,
33    pub reasoning_effort: Option<ReasoningEffort>,
34    pub sandbox_spec: Option<SandboxSpec>,
35    pub messages: Vec<Message>,
36    pub last_response_duration_ms: Option<u64>,
37    pub previous_response_duration_ms: Option<u64>,
38    pub response_durations_ms: Option<Vec<Option<u64>>>,
39    pub timeline_json: Option<String>,
40    pub created_at: String,
41    pub updated_at: String,
42}
43
44#[derive(Debug, Clone)]
45pub struct SessionSummary {
46    pub session_id: String,
47    pub cwd: PathBuf,
48    pub model: String,
49    pub backend: BackendKind,
50    pub visible_message_count: usize,
51    pub last_user_prompt: Option<String>,
52    pub sandboxed: bool,
53    pub created_at: String,
54    pub updated_at: String,
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::test_env_lock;
61    use crate::types::Message;
62
63    fn temp_store_path(label: &str) -> PathBuf {
64        let unique = std::time::SystemTime::now()
65            .duration_since(std::time::UNIX_EPOCH)
66            .expect("time went backwards")
67            .as_nanos();
68        std::env::temp_dir()
69            .join(format!("sac_sessions_test_{}_{}", label, unique))
70            .join("store.db")
71    }
72
73    #[test]
74    fn create_and_load_session_round_trip() {
75        let _guard = test_env_lock();
76        let store_path = temp_store_path("round_trip");
77
78        let mut snapshot = new_snapshot(
79            "session-1".to_string(),
80            PathBuf::from("/repo"),
81            store_path.clone(),
82            "model-a".to_string(),
83            "https://api.openai.com/v1".to_string(),
84            BackendKind::OpenAiResponses,
85            Some(ReasoningEffort::Xhigh),
86            None,
87            vec![Message::User {
88                content: "hello".to_string(),
89            }],
90        );
91        snapshot.last_response_duration_ms = Some(12_345);
92        snapshot.previous_response_duration_ms = Some(6_789);
93        snapshot.response_durations_ms = Some(vec![Some(1_000), None, Some(12_345)]);
94        create_session(&snapshot).unwrap();
95        let loaded = load_session(&store_path, "session-1").unwrap();
96        assert_eq!(loaded.session_id, "session-1");
97        assert_eq!(loaded.cwd, PathBuf::from("/repo"));
98        assert_eq!(loaded.messages.len(), 1);
99        assert_eq!(loaded.last_response_duration_ms, Some(12_345));
100        assert_eq!(loaded.previous_response_duration_ms, Some(6_789));
101        assert_eq!(
102            loaded.response_durations_ms,
103            Some(vec![Some(1_000), None, Some(12_345)])
104        );
105
106        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
107    }
108
109    #[test]
110    fn load_session_migrates_legacy_schema_without_duration_history() {
111        let _guard = test_env_lock();
112        let store_path = temp_store_path("legacy_duration_schema");
113        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
114        let messages_json = serde_json::to_string(&vec![Message::User {
115            content: "hello".to_string(),
116        }])
117        .unwrap();
118
119        {
120            let conn = rusqlite::Connection::open(&store_path).unwrap();
121            conn.execute_batch(
122                "CREATE TABLE sessions (
123                    session_id TEXT PRIMARY KEY,
124                    cwd TEXT NOT NULL,
125                    store_path TEXT NOT NULL,
126                    model TEXT NOT NULL,
127                    base_url TEXT NOT NULL,
128                    backend TEXT,
129                    reasoning_effort TEXT,
130                    sandbox_json TEXT,
131                    messages_json TEXT NOT NULL,
132                    last_response_duration_ms INTEGER,
133                    previous_response_duration_ms INTEGER,
134                    created_at TEXT NOT NULL,
135                    updated_at TEXT NOT NULL
136                );",
137            )
138            .unwrap();
139            conn.execute(
140                "INSERT INTO sessions (
141                    session_id, cwd, store_path, model, base_url, backend, reasoning_effort,
142                    sandbox_json, messages_json, last_response_duration_ms,
143                    previous_response_duration_ms, created_at, updated_at
144                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
145                rusqlite::params![
146                    "legacy-session",
147                    "/repo",
148                    store_path.display().to_string(),
149                    "model-a",
150                    "https://api.openai.com/v1",
151                    "openai-responses",
152                    "xhigh",
153                    Option::<String>::None,
154                    messages_json,
155                    12_345_u64,
156                    6_789_u64,
157                    "2026-01-01 00:00:00.000000000",
158                    "2026-01-01 00:00:01.000000000",
159                ],
160            )
161            .unwrap();
162        }
163
164        let loaded = load_session(&store_path, "legacy-session").unwrap();
165        assert_eq!(loaded.session_id, "legacy-session");
166        assert_eq!(loaded.last_response_duration_ms, Some(12_345));
167        assert_eq!(loaded.previous_response_duration_ms, Some(6_789));
168        assert_eq!(loaded.response_durations_ms, None);
169
170        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
171    }
172
173    #[test]
174    fn load_last_session_returns_most_recent() {
175        let _guard = test_env_lock();
176        let store_path = temp_store_path("latest");
177
178        let first = new_snapshot(
179            "session-1".to_string(),
180            PathBuf::from("/repo-one"),
181            store_path.clone(),
182            "model-a".to_string(),
183            "https://api.openai.com/v1".to_string(),
184            BackendKind::OpenAiResponses,
185            Some(ReasoningEffort::Xhigh),
186            None,
187            Vec::new(),
188        );
189        create_session(&first).unwrap();
190
191        let second = new_snapshot(
192            "session-2".to_string(),
193            PathBuf::from("/repo-two"),
194            store_path.clone(),
195            "model-b".to_string(),
196            "https://api.fireworks.ai/inference/v1".to_string(),
197            BackendKind::FireworksChat,
198            None,
199            None,
200            vec![Message::User {
201                content: "latest".to_string(),
202            }],
203        );
204        save_session(&second).unwrap();
205
206        let loaded = load_last_session(&store_path).unwrap();
207        assert_eq!(loaded.session_id, "session-2");
208
209        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
210    }
211
212    #[test]
213    fn list_sessions_returns_summaries_in_updated_order() {
214        let _guard = test_env_lock();
215        let store_path = temp_store_path("list");
216
217        let first = new_snapshot(
218            "session-1".to_string(),
219            PathBuf::from("/repo-one"),
220            store_path.clone(),
221            "model-a".to_string(),
222            "https://api.openai.com/v1".to_string(),
223            BackendKind::OpenAiResponses,
224            None,
225            None,
226            vec![
227                Message::System {
228                    content: "system".to_string(),
229                },
230                Message::User {
231                    content: "first prompt".to_string(),
232                },
233            ],
234        );
235        create_session(&first).unwrap();
236
237        let second = new_snapshot(
238            "session-2".to_string(),
239            PathBuf::from("/repo-two"),
240            store_path.clone(),
241            "model-b".to_string(),
242            "https://api.fireworks.ai/inference/v1".to_string(),
243            BackendKind::FireworksChat,
244            None,
245            Some(SandboxSpec {
246                image: "python:3.13".to_string(),
247                workdir: PathBuf::from("/workspace"),
248                mounts: Vec::new(),
249                gpu_devices: Vec::new(),
250                shm_size: Some("0".to_string()),
251            }),
252            vec![
253                Message::System {
254                    content: "system".to_string(),
255                },
256                Message::User {
257                    content: "latest prompt".to_string(),
258                },
259                Message::Assistant {
260                    content: Some("reply".to_string()),
261                    reasoning_text: None,
262                    reasoning_details: None,
263                    tool_calls: None,
264                },
265            ],
266        );
267        save_session(&second).unwrap();
268
269        let sessions = list_sessions(&store_path).unwrap();
270        assert_eq!(sessions.len(), 2);
271        assert_eq!(sessions[0].session_id, "session-2");
272        assert_eq!(sessions[0].visible_message_count, 2);
273        assert_eq!(
274            sessions[0].last_user_prompt.as_deref(),
275            Some("latest prompt")
276        );
277        assert!(sessions[0].sandboxed);
278
279        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
280    }
281}