1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use tracing::{debug, info};
11use uuid::Uuid;
12
13use crate::llm::message::Message;
14use crate::services::secret_masker;
15
16#[derive(Debug, Serialize, Deserialize)]
21pub struct SessionData {
22 pub id: String,
24 pub created_at: String,
26 pub updated_at: String,
28 pub cwd: String,
30 pub model: String,
32 pub messages: Vec<Message>,
34 pub turn_count: usize,
36 #[serde(default)]
38 pub total_cost_usd: f64,
39 #[serde(default)]
41 pub total_input_tokens: u64,
42 #[serde(default)]
44 pub total_output_tokens: u64,
45 #[serde(default)]
47 pub plan_mode: bool,
48}
49
50fn sessions_dir() -> Option<PathBuf> {
52 dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
53}
54
55pub(crate) fn serialize_masked(data: &SessionData) -> Result<String, String> {
60 let json = serde_json::to_string_pretty(data)
61 .map_err(|e| format!("Failed to serialize session: {e}"))?;
62 Ok(secret_masker::mask(&json))
63}
64
65pub fn save_session(
67 session_id: &str,
68 messages: &[Message],
69 cwd: &str,
70 model: &str,
71 turn_count: usize,
72) -> Result<PathBuf, String> {
73 save_session_full(
74 session_id, messages, cwd, model, turn_count, 0.0, 0, 0, false,
75 )
76}
77
78#[allow(clippy::too_many_arguments)]
80pub fn save_session_full(
81 session_id: &str,
82 messages: &[Message],
83 cwd: &str,
84 model: &str,
85 turn_count: usize,
86 total_cost_usd: f64,
87 total_input_tokens: u64,
88 total_output_tokens: u64,
89 plan_mode: bool,
90) -> Result<PathBuf, String> {
91 let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
92 std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create sessions dir: {e}"))?;
93
94 let path = dir.join(format!("{session_id}.json"));
95
96 let created_at = if path.exists() {
98 std::fs::read_to_string(&path)
99 .ok()
100 .and_then(|c| serde_json::from_str::<SessionData>(&c).ok())
101 .map(|d| d.created_at)
102 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
103 } else {
104 chrono::Utc::now().to_rfc3339()
105 };
106
107 let data = SessionData {
108 id: session_id.to_string(),
109 created_at,
110 updated_at: chrono::Utc::now().to_rfc3339(),
111 cwd: cwd.to_string(),
112 model: model.to_string(),
113 messages: messages.to_vec(),
114 turn_count,
115 total_cost_usd,
116 total_input_tokens,
117 total_output_tokens,
118 plan_mode,
119 };
120
121 let json = serialize_masked(&data)?;
126
127 std::fs::write(&path, json).map_err(|e| format!("Failed to write session file: {e}"))?;
128
129 debug!("Session saved: {}", path.display());
130 Ok(path)
131}
132
133pub fn load_session(session_id: &str) -> Result<SessionData, String> {
135 let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
136 let path = dir.join(format!("{session_id}.json"));
137
138 if !path.exists() {
139 return Err(format!("Session '{session_id}' not found"));
140 }
141
142 let content =
143 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read session: {e}"))?;
144
145 let data: SessionData =
146 serde_json::from_str(&content).map_err(|e| format!("Failed to parse session: {e}"))?;
147
148 info!(
149 "Session loaded: {} ({} messages)",
150 session_id,
151 data.messages.len()
152 );
153 Ok(data)
154}
155
156pub fn list_sessions(limit: usize) -> Vec<SessionSummary> {
158 let dir = match sessions_dir() {
159 Some(d) if d.is_dir() => d,
160 _ => return Vec::new(),
161 };
162
163 let mut sessions: Vec<SessionSummary> = std::fs::read_dir(&dir)
164 .ok()
165 .into_iter()
166 .flatten()
167 .flatten()
168 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
169 .filter_map(|entry| {
170 let content = std::fs::read_to_string(entry.path()).ok()?;
171 let data: SessionData = serde_json::from_str(&content).ok()?;
172 Some(SessionSummary {
173 id: data.id,
174 cwd: data.cwd,
175 model: data.model,
176 turn_count: data.turn_count,
177 message_count: data.messages.len(),
178 updated_at: data.updated_at,
179 })
180 })
181 .collect();
182
183 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
184 sessions.truncate(limit);
185 sessions
186}
187
188#[derive(Debug)]
190pub struct SessionSummary {
191 pub id: String,
192 pub cwd: String,
193 pub model: String,
194 pub turn_count: usize,
195 pub message_count: usize,
196 pub updated_at: String,
197}
198
199pub fn new_session_id() -> String {
201 Uuid::new_v4()
202 .to_string()
203 .split('-')
204 .next()
205 .unwrap_or("session")
206 .to_string()
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::llm::message::{ContentBlock, Message, UserMessage, user_message};
213
214 fn make_session(messages: Vec<Message>) -> SessionData {
217 SessionData {
218 id: "fixture".into(),
219 created_at: "2026-04-15T00:00:00Z".into(),
220 updated_at: "2026-04-15T00:00:00Z".into(),
221 cwd: "/work".into(),
222 model: "test-model".into(),
223 messages,
224 turn_count: 1,
225 total_cost_usd: 0.0,
226 total_input_tokens: 0,
227 total_output_tokens: 0,
228 plan_mode: false,
229 }
230 }
231
232 fn tool_result_user_message(tool_use_id: &str, content: &str) -> Message {
235 Message::User(UserMessage {
236 uuid: uuid::Uuid::new_v4(),
237 timestamp: "2026-04-15T00:00:00Z".to_string(),
238 content: vec![ContentBlock::ToolResult {
239 tool_use_id: tool_use_id.to_string(),
240 content: content.to_string(),
241 is_error: false,
242 extra_content: Vec::new(),
243 }],
244 is_meta: false,
245 is_compact_summary: false,
246 })
247 }
248
249 #[test]
250 fn test_new_session_id_format() {
251 let id = new_session_id();
252 assert!(!id.is_empty());
253 assert!(!id.contains('-')); assert!(id.len() == 8); }
256
257 #[test]
258 fn test_new_session_id_unique() {
259 let id1 = new_session_id();
260 let id2 = new_session_id();
261 assert_ne!(id1, id2);
262 }
263
264 #[test]
265 fn test_save_and_load_session() {
266 let dir = tempfile::tempdir().unwrap();
268 let session_id = "test-save-load";
269 let session_file = dir.path().join(format!("{session_id}.json"));
270
271 let messages = vec![user_message("hello"), user_message("world")];
272
273 let data = SessionData {
275 id: session_id.to_string(),
276 created_at: chrono::Utc::now().to_rfc3339(),
277 updated_at: chrono::Utc::now().to_rfc3339(),
278 cwd: "/tmp".to_string(),
279 model: "test-model".to_string(),
280 messages: messages.clone(),
281 turn_count: 5,
282 total_cost_usd: 0.0,
283 total_input_tokens: 0,
284 total_output_tokens: 0,
285 plan_mode: false,
286 };
287 let json = serde_json::to_string_pretty(&data).unwrap();
288 std::fs::create_dir_all(dir.path()).unwrap();
289 std::fs::write(&session_file, &json).unwrap();
290
291 let loaded: SessionData = serde_json::from_str(&json).unwrap();
293 assert_eq!(loaded.id, session_id);
294 assert_eq!(loaded.cwd, "/tmp");
295 assert_eq!(loaded.model, "test-model");
296 assert_eq!(loaded.turn_count, 5);
297 assert_eq!(loaded.messages.len(), 2);
298 }
299
300 #[test]
301 fn test_session_data_serialization_roundtrip() {
302 let data = SessionData {
303 id: "abc123".to_string(),
304 created_at: "2026-01-01T00:00:00Z".to_string(),
305 updated_at: "2026-01-01T00:00:00Z".to_string(),
306 cwd: "/home/user/project".to_string(),
307 model: "claude-sonnet-4".to_string(),
308 messages: vec![user_message("test")],
309 turn_count: 3,
310 total_cost_usd: 0.05,
311 total_input_tokens: 1000,
312 total_output_tokens: 500,
313 plan_mode: false,
314 };
315
316 let json = serde_json::to_string(&data).unwrap();
317 let loaded: SessionData = serde_json::from_str(&json).unwrap();
318 assert_eq!(loaded.id, data.id);
319 assert_eq!(loaded.model, data.model);
320 assert_eq!(loaded.turn_count, data.turn_count);
321 }
322
323 #[test]
324 fn serialize_masked_redacts_secrets_in_messages() {
325 let aws_key = "AKIAIOSFODNN7EXAMPLE";
329 let data = SessionData {
330 id: "sess-1".to_string(),
331 created_at: "2026-04-15T00:00:00Z".to_string(),
332 updated_at: "2026-04-15T00:00:00Z".to_string(),
333 cwd: "/work".to_string(),
334 model: "test-model".to_string(),
335 messages: vec![user_message(format!("here is my key {aws_key}"))],
336 turn_count: 1,
337 total_cost_usd: 0.0,
338 total_input_tokens: 0,
339 total_output_tokens: 0,
340 plan_mode: false,
341 };
342 let out = serialize_masked(&data).unwrap();
343 assert!(
344 !out.contains(aws_key),
345 "raw AWS key survived serialization: {out}",
346 );
347 assert!(out.contains("[REDACTED:aws_access_key]"));
348 assert!(out.contains("\"cwd\": \"/work\""));
350 assert!(out.contains("\"model\": \"test-model\""));
351 }
352
353 #[test]
354 fn serialize_masked_redacts_generic_credential_assignments() {
355 let secret_line = "api_key=verylongprovidersecret1234567890";
356 let data = SessionData {
357 id: "sess-2".to_string(),
358 created_at: "2026-04-15T00:00:00Z".to_string(),
359 updated_at: "2026-04-15T00:00:00Z".to_string(),
360 cwd: "/work".to_string(),
361 model: "test-model".to_string(),
362 messages: vec![user_message(secret_line)],
363 turn_count: 1,
364 total_cost_usd: 0.0,
365 total_input_tokens: 0,
366 total_output_tokens: 0,
367 plan_mode: false,
368 };
369 let out = serialize_masked(&data).unwrap();
370 assert!(!out.contains("verylongprovidersecret1234567890"));
371 assert!(out.contains("[REDACTED:credential]"));
372 }
373
374 #[test]
379 fn serialize_masked_produces_parseable_json_for_unquoted_inner_secret() {
380 let data = SessionData {
381 id: "probe".to_string(),
382 created_at: "2026-04-15T00:00:00Z".to_string(),
383 updated_at: "2026-04-15T00:00:00Z".to_string(),
384 cwd: "/work".to_string(),
385 model: "test-model".to_string(),
386 messages: vec![user_message("api_key=hunter2hunter2")],
387 turn_count: 1,
388 total_cost_usd: 0.0,
389 total_input_tokens: 0,
390 total_output_tokens: 0,
391 plan_mode: false,
392 };
393 let out = serialize_masked(&data).unwrap();
394 let parsed: Result<SessionData, _> = serde_json::from_str(&out);
396 assert!(
397 parsed.is_ok(),
398 "masked session JSON failed to round-trip: {}\n---\n{out}",
399 parsed.err().unwrap(),
400 );
401 let loaded = parsed.unwrap();
402 assert_eq!(loaded.id, "probe");
403 assert_eq!(loaded.messages.len(), 1);
404 }
405
406 #[test]
407 fn serialize_masked_produces_parseable_json_for_multiple_secret_shapes() {
408 let shapes = [
409 "my api_key=hunter2hunter2",
410 "password: sup3rs3cr3tv@lue (truncated)",
411 r#"env DATABASE_URL=postgres://user:hunter2hunter2@host/db"#,
412 "auth_token = abcdefghijklmn",
413 "mixed: api_key=abcd1234efgh5678 and token=xyz12345abcd6789",
414 ];
415 for shape in shapes {
416 let data = SessionData {
417 id: "probe".to_string(),
418 created_at: "2026-04-15T00:00:00Z".to_string(),
419 updated_at: "2026-04-15T00:00:00Z".to_string(),
420 cwd: "/work".to_string(),
421 model: "test-model".to_string(),
422 messages: vec![user_message(shape.to_string())],
423 turn_count: 1,
424 total_cost_usd: 0.0,
425 total_input_tokens: 0,
426 total_output_tokens: 0,
427 plan_mode: false,
428 };
429 let out = serialize_masked(&data).unwrap();
430 let parsed: Result<SessionData, _> = serde_json::from_str(&out);
431 assert!(
432 parsed.is_ok(),
433 "shape corrupted JSON: {shape:?}\nerr: {}\nout: {out}",
434 parsed.err().unwrap(),
435 );
436 }
437 }
438
439 #[test]
440 fn serialize_masked_redacts_secret_in_tool_result_block() {
441 let leaked = "export AWS_SECRET_ACCESS_KEY=abcdefghijklmnopqrstuvwxyz1234";
445 let data = make_session(vec![tool_result_user_message("call-1", leaked)]);
446 let out = serialize_masked(&data).unwrap();
447 assert!(
448 !out.contains("abcdefghijklmnopqrstuvwxyz1234"),
449 "tool_result secret survived serialization",
450 );
451 assert!(out.contains("REDACTED"));
452 let _: SessionData =
454 serde_json::from_str(&out).expect("tool_result session must round-trip");
455 }
456
457 #[test]
458 fn serialize_masked_handles_many_messages_with_mixed_secrets() {
459 let messages = vec![
462 user_message("AKIAIOSFODNN7EXAMPLE leaked in user message"),
463 tool_result_user_message(
464 "t1",
465 r#"env dump: DATABASE_URL=postgres://user:hunter2hunter2@host/db"#,
466 ),
467 user_message("auth_token = abcdefghijklmnop"),
468 tool_result_user_message("t2", "config.toml says api_key = \"secretprovidervalue\""),
469 ];
470 let data = make_session(messages);
471 let out = serialize_masked(&data).unwrap();
472
473 for needle in [
475 "AKIAIOSFODNN7EXAMPLE",
476 "hunter2hunter2",
477 "abcdefghijklmnop",
478 "secretprovidervalue",
479 ] {
480 assert!(!out.contains(needle), "leaked {needle} in: {out}",);
481 }
482 assert!(out.matches("REDACTED").count() >= 4);
484 let parsed: SessionData =
486 serde_json::from_str(&out).expect("mixed-secret session must round-trip");
487 assert_eq!(parsed.messages.len(), 4);
488 }
489
490 #[test]
491 fn serialize_masked_is_idempotent_save_load_save() {
492 let data = make_session(vec![
496 user_message("AKIAIOSFODNN7EXAMPLE and api_key=hunter2hunter2"),
497 tool_result_user_message(
498 "t1",
499 "ghp_abcdefghijklmnopqrstuvwxyz0123456789 then password='firstpassword1234'",
500 ),
501 ]);
502
503 let first = serialize_masked(&data).unwrap();
504 let loaded: SessionData = serde_json::from_str(&first).expect("first save must parse");
505
506 let second = serialize_masked(&loaded).unwrap();
510
511 assert_eq!(
512 first, second,
513 "save→load→save is not idempotent\nfirst:\n{first}\nsecond:\n{second}",
514 );
515 }
516
517 #[test]
518 fn serialize_masked_leaves_innocuous_content_intact() {
519 let data = SessionData {
520 id: "sess-3".to_string(),
521 created_at: "2026-04-15T00:00:00Z".to_string(),
522 updated_at: "2026-04-15T00:00:00Z".to_string(),
523 cwd: "/work".to_string(),
524 model: "test-model".to_string(),
525 messages: vec![user_message("fn main() { println!(\"hello\"); }")],
526 turn_count: 1,
527 total_cost_usd: 0.0,
528 total_input_tokens: 0,
529 total_output_tokens: 0,
530 plan_mode: false,
531 };
532 let out = serialize_masked(&data).unwrap();
533 assert!(!out.contains("REDACTED"));
534 assert!(out.contains("fn main()"));
535 }
536
537 #[test]
538 fn test_session_summary_fields() {
539 let summary = SessionSummary {
540 id: "xyz".to_string(),
541 cwd: "/tmp".to_string(),
542 model: "gpt-4".to_string(),
543 turn_count: 10,
544 message_count: 20,
545 updated_at: "2026-03-31".to_string(),
546 };
547 assert_eq!(summary.id, "xyz");
548 assert_eq!(summary.turn_count, 10);
549 assert_eq!(summary.message_count, 20);
550 }
551}