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}