1use std::fs::File;
2use std::io::{self, BufRead, Write as _};
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5use std::time::SystemTime;
6
7use agent_base::{AgentError, AgentResult, ChatMessage};
8use fs2::FileExt;
9use regex::Regex;
10
11static SESSION_ID_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
12static SNAPSHOT_NAME_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
13
14#[derive(Debug)]
18pub struct SessionContext {
19 pub session_id: String,
21 pub session_dir: PathBuf,
23 pub base_dir: PathBuf,
25 pub is_new_session: bool,
27 _lock: Option<File>,
28}
29
30impl SessionContext {
31 #[allow(dead_code)]
33 pub fn session_id_path(&self) -> PathBuf {
34 self.session_dir.join("session_id")
35 }
36
37 pub fn metadata_path(&self) -> PathBuf {
39 self.session_dir.join("session_meta.json")
40 }
41
42 pub fn log_path(&self) -> PathBuf {
44 self.session_dir.join("session.log")
45 }
46
47 pub fn turn_path(&self, turn: usize) -> PathBuf {
49 self.session_dir.join(format!("turn_{:03}.jsonl", turn))
50 }
51
52 pub fn messages_jsonl_path(&self) -> PathBuf {
54 self.session_dir.join("messages.jsonl")
55 }
56
57 pub fn last_turn_number(&self) -> u32 {
65 let Ok(rd) = std::fs::read_dir(&self.session_dir) else {
66 return 0;
67 };
68 let mut max = 0u32;
69 for entry in rd.flatten() {
70 let name = entry.file_name();
71 let name = name.to_string_lossy();
72 if let Some(n) =
73 name.strip_prefix("turn_").and_then(|s| s.strip_suffix(".jsonl")).and_then(|s| s.parse::<u32>().ok())
74 {
75 max = max.max(n);
76 }
77 }
78 max
79 }
80}
81
82pub fn validate_session_id(session_id: &str) -> AgentResult<()> {
86 if session_id.is_empty() || session_id.len() > 128 {
87 return Err(AgentError::config_error(format!("Session ID must be 1-128 characters, got {}", session_id.len())));
88 }
89
90 if !SESSION_ID_RE.is_match(session_id) {
91 return Err(AgentError::config_error(format!(
92 "Invalid session_id format '{}'. Only alphanumeric, hyphens, and underscores allowed.",
93 session_id
94 )));
95 }
96
97 Ok(())
98}
99
100pub fn validate_snapshot_name(name: &str) -> AgentResult<()> {
105 if name.is_empty() || name.len() > 64 {
106 return Err(AgentError::config_error(format!("Snapshot name must be 1-64 characters, got {}", name.len())));
107 }
108
109 if name.contains('/') || name.contains('\\') || name.contains("..") {
110 return Err(AgentError::config_error(format!(
111 "Invalid snapshot name '{}': path separators and '..' are not allowed",
112 name
113 )));
114 }
115
116 if !SNAPSHOT_NAME_RE.is_match(name) {
117 return Err(AgentError::config_error(format!(
118 "Invalid snapshot name '{}'. Only alphanumeric, hyphens, and underscores allowed.",
119 name
120 )));
121 }
122
123 Ok(())
124}
125
126pub fn resolve_session_id(cli_session_id: Option<&str>) -> AgentResult<String> {
128 if let Some(id) = cli_session_id {
129 validate_session_id(id)?;
130 return Ok(id.to_string());
131 }
132
133 if let Ok(id) = std::env::var("PHI_SESSION_ID")
134 && !id.is_empty()
135 {
136 validate_session_id(&id)?;
137 return Ok(id);
138 }
139
140 Ok(generate_session_id())
141}
142
143pub fn generate_session_id() -> String {
145 let now = chrono::Local::now();
146 let uuid = uuid::Uuid::new_v4().to_string();
147 let uuid_short = &uuid[..8.min(uuid.len())];
148 format!("{}_{}", now.format("%Y%m%d"), uuid_short)
149}
150
151pub fn get_or_create_session_dir(session_id: &str, base_dir: &Path) -> AgentResult<(PathBuf, bool)> {
155 let session_dir = base_dir.join("sessions").join(session_id);
156 let is_new = !session_dir.exists();
157
158 if is_new {
159 std::fs::create_dir_all(&session_dir)?;
160 tracing::info!(session_id = %session_id, path = %session_dir.display(), "created new session directory");
161 } else {
162 tracing::info!(session_id = %session_id, path = %session_dir.display(), "reusing existing session directory");
163 }
164
165 std::fs::write(session_dir.join("session_id"), session_id)?;
167
168 update_session_meta(&session_dir, session_id)?;
170
171 Ok((session_dir, is_new))
172}
173
174pub fn acquire_session_lock(session_dir: &Path) -> AgentResult<File> {
179 let lock_path = session_dir.join("session.lock");
180 let file = File::create(&lock_path)?;
181
182 file.try_lock_exclusive().map_err(|_| {
183 AgentError::resource_unavailable(format!(
184 "Session '{}' is currently in use by another process",
185 session_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
186 ))
187 })?;
188
189 Ok(file)
190}
191
192fn update_session_meta(session_dir: &Path, session_id: &str) -> AgentResult<()> {
194 let meta_path = session_dir.join("session_meta.json");
195
196 let mut meta = if meta_path.exists() {
197 let content = std::fs::read_to_string(&meta_path)?;
198 serde_json::from_str::<serde_json::Value>(&content)?
199 } else {
200 serde_json::json!({
201 "session_id": session_id,
202 "created_at": chrono::Utc::now().to_rfc3339(),
203 })
204 };
205
206 meta["last_active_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
207
208 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
209 Ok(())
210}
211
212pub fn read_session_title(session_dir: &Path) -> Option<String> {
216 let meta_path = session_dir.join("session_meta.json");
217 let content = std::fs::read_to_string(&meta_path).ok()?;
218 let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
219 meta["title"].as_str().filter(|s| !s.is_empty()).map(String::from)
220}
221
222pub fn write_session_title(session_dir: &Path, title: &str, generated: bool) -> io::Result<()> {
229 let meta_path = session_dir.join("session_meta.json");
230 let mut meta = if meta_path.exists() {
231 let content = std::fs::read_to_string(&meta_path)?;
232 serde_json::from_str::<serde_json::Value>(&content).unwrap_or(serde_json::json!({}))
233 } else {
234 serde_json::json!({})
235 };
236
237 meta["title"] = serde_json::json!(title);
238 meta["title_generated"] = serde_json::json!(generated);
239
240 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
241 Ok(())
242}
243
244pub fn cleanup_expired_sessions(base_dir: &Path, max_age_days: i64) -> AgentResult<u32> {
249 let sessions_dir = base_dir.join("sessions");
250 if !sessions_dir.exists() {
251 return Ok(0);
252 }
253
254 let now = chrono::Utc::now();
255 let mut cleaned = 0;
256
257 for entry in std::fs::read_dir(&sessions_dir)? {
258 let entry = entry?;
259 let path = entry.path();
260
261 if !path.is_dir() {
262 continue;
263 }
264
265 let lock_path = path.join("session.lock");
266 if lock_path.exists()
267 && let Ok(file) = File::open(&lock_path)
268 && file.try_lock_shared().is_err()
269 {
270 continue; }
272
273 let meta_path = path.join("session_meta.json");
274 if !meta_path.exists() {
275 std::fs::remove_dir_all(&path)?;
276 cleaned += 1;
277 continue;
278 }
279
280 let content = std::fs::read_to_string(&meta_path)?;
281 let meta: serde_json::Value = serde_json::from_str(&content)?;
282
283 if let Some(last_active) = meta["last_active_at"].as_str()
284 && let Ok(last_active) = chrono::DateTime::parse_from_rfc3339(last_active)
285 {
286 let age = now - last_active.with_timezone(&chrono::Utc);
287 if age.num_days() > max_age_days {
288 tracing::info!(path = %path.display(), age_days = age.num_days(), "removing expired session");
289 std::fs::remove_dir_all(&path)?;
290 cleaned += 1;
291 }
292 }
293 }
294
295 if cleaned > 0 {
296 tracing::info!(count = cleaned, "cleaned up expired sessions");
297 }
298
299 Ok(cleaned)
300}
301
302#[derive(Debug)]
306pub struct SessionInfo {
307 pub session_id: String,
309 pub title: String,
311 pub last_active_at: SystemTime,
313 pub session_dir: PathBuf,
315}
316
317pub fn list_sessions(base_dir: &Path, current_session_id: Option<&str>) -> Vec<SessionInfo> {
323 let sessions_dir = base_dir.join("sessions");
324 if !sessions_dir.exists() {
325 return Vec::new();
326 }
327
328 let mut entries = Vec::new();
329
330 for dir_entry in match std::fs::read_dir(&sessions_dir) {
331 Ok(rd) => rd,
332 Err(_) => return Vec::new(),
333 }
334 .flatten()
335 {
336 let path = dir_entry.path();
337 if !path.is_dir() {
338 continue;
339 }
340
341 let session_id = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
342
343 if Some(session_id.as_str()) == current_session_id {
345 continue;
346 }
347
348 let lock_path = path.join("session.lock");
350 if let Ok(file) = File::open(&lock_path) {
351 if file.try_lock_shared().is_err() {
352 continue;
353 }
354 drop(file);
355 }
356
357 let messages_path = path.join("messages.jsonl");
359 if !messages_path.exists() {
360 continue;
361 }
362
363 let title = extract_session_title(&path);
364 if title.is_empty() {
365 continue;
366 }
367
368 let mtime = std::fs::metadata(&messages_path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
369
370 entries.push(SessionInfo { session_id, title, last_active_at: mtime, session_dir: path });
371 }
372
373 entries.sort_by_key(|e| std::cmp::Reverse(e.last_active_at));
374 entries
375}
376
377fn extract_session_title(session_dir: &Path) -> String {
381 if let Some(title) = read_session_title(session_dir) {
383 return title;
384 }
385
386 let messages_path = session_dir.join("messages.jsonl");
388 if let Ok(file) = File::open(&messages_path) {
389 let reader = io::BufReader::new(file);
390 for line in reader.lines().map_while(Result::ok) {
391 if line.trim().is_empty() {
392 continue;
393 }
394 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&line)
395 && let Some(content) = msg.get("User").and_then(|u| u.get("content")).and_then(|c| c.as_str())
396 {
397 return truncate_display(content, 60);
398 }
399 }
400 }
401
402 let turn_path = session_dir.join("turn_001.jsonl");
404 if let Ok(file) = File::open(&turn_path) {
405 let reader = io::BufReader::new(file);
406 for line in reader.lines().map_while(Result::ok) {
407 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line)
408 && let Some(input) = val.get("user_input").and_then(|u| u.as_str())
409 {
410 return truncate_display(input, 60);
411 }
412 }
413 }
414
415 String::new()
416}
417
418fn truncate_display(s: &str, max: usize) -> String {
420 let clean: String = s.split_whitespace().collect::<Vec<&str>>().join(" ");
421 if clean.chars().count() <= max {
422 clean
423 } else {
424 let truncated: String = clean.chars().take(max).collect();
425 format!("{}...", truncated)
426 }
427}
428
429fn messages_jsonl_path(session_dir: &Path) -> PathBuf {
433 session_dir.join("messages.jsonl")
434}
435
436fn strip_stale_fields(msg: &mut ChatMessage) {
439 if let ChatMessage::Assistant { reasoning_content, thinking_signature, .. } = msg {
440 *reasoning_content = None;
441 *thinking_signature = None;
442 }
443}
444
445pub fn persist_window_messages(session_dir: &Path, messages: &[ChatMessage]) -> io::Result<()> {
451 let path = messages_jsonl_path(session_dir);
452 let tmp = path.with_extension("jsonl.tmp");
453
454 let mut file = File::create(&tmp)?;
455 for mut msg in messages.iter().cloned() {
456 match &msg {
457 ChatMessage::System { .. } => continue,
458 ChatMessage::User { ephemeral: true, .. } => continue,
459 _ => {},
460 }
461 strip_stale_fields(&mut msg);
462 serde_json::to_writer(&mut file, &msg)?;
463 file.write_all(b"\n")?;
464 }
465 file.flush()?;
466 drop(file);
467
468 std::fs::rename(&tmp, &path)?;
469 Ok(())
470}
471
472pub fn load_session_messages(session_dir: &Path) -> io::Result<Vec<ChatMessage>> {
482 let path = messages_jsonl_path(session_dir);
483 if !path.exists() {
484 return Ok(Vec::new());
485 }
486
487 let file = File::open(&path)?;
488 let reader = io::BufReader::new(file);
489 let mut messages = Vec::new();
490
491 for line_result in reader.lines() {
492 let line = line_result?;
493 if line.trim().is_empty() {
494 continue;
495 }
496 let mut msg: ChatMessage = match serde_json::from_str(&line) {
497 Ok(m) => m,
498 Err(e) => {
499 tracing::warn!(error = %e, "skipping malformed messages.jsonl line");
500 continue;
501 },
502 };
503 if matches!(msg, ChatMessage::System { .. }) {
505 continue;
506 }
507 strip_stale_fields(&mut msg);
508 messages.push(msg);
509 }
510
511 let mut pending: Vec<String> = Vec::new();
515 for msg in &messages {
516 match msg {
517 ChatMessage::Assistant { tool_calls: Some(tcs), .. } => {
518 pending = tcs.iter().map(|t| t.id.clone()).collect();
519 },
520 ChatMessage::Tool { tool_call_id, .. } => {
521 pending.retain(|id| id != tool_call_id);
522 },
523 _ => {},
524 }
525 }
526 for id in pending {
527 tracing::info!(tool_call_id = %id, "patching dangling tool_call with interrupted response");
528 messages.push(ChatMessage::Tool { tool_call_id: id, name: None, content: "interrupted".to_string() });
529 }
530
531 Ok(messages)
532}
533
534pub fn clear_messages_jsonl(session_dir: &Path) -> io::Result<()> {
540 let path = messages_jsonl_path(session_dir);
541 if path.exists() {
542 std::fs::remove_file(&path)?;
543 }
544 Ok(())
545}
546
547pub fn create_snapshot(session_ctx: &SessionContext, name: &str, base_dir: &Path) -> AgentResult<PathBuf> {
554 validate_snapshot_name(name)?;
555 let snapshot_dir = base_dir.join("snapshots").join(name);
556
557 if snapshot_dir.exists() {
558 std::fs::remove_dir_all(&snapshot_dir)?;
559 }
560 std::fs::create_dir_all(&snapshot_dir)?;
561
562 let meta_path = session_ctx.metadata_path();
564 if meta_path.exists() {
565 std::fs::copy(&meta_path, snapshot_dir.join("session_meta.json"))?;
566 }
567
568 let mut turn_count = 0u32;
570 for entry in std::fs::read_dir(&session_ctx.session_dir)? {
571 let entry = entry?;
572 let name = entry.file_name();
573 let name_str = name.to_string_lossy();
574 if name_str.starts_with("turn_") && name_str.ends_with(".jsonl") {
575 std::fs::copy(entry.path(), snapshot_dir.join(&*name_str))?;
576 turn_count += 1;
577 }
578 }
579
580 let info = serde_json::json!({
582 "session_id": session_ctx.session_id,
583 "snapshot_name": name,
584 "created_at": chrono::Utc::now().to_rfc3339(),
585 "turn_count": turn_count,
586 });
587 std::fs::write(snapshot_dir.join("snapshot_info.json"), serde_json::to_string_pretty(&info)?)?;
588
589 tracing::info!(
590 session_id = %session_ctx.session_id,
591 name = name,
592 turns = turn_count,
593 "session snapshot created"
594 );
595
596 Ok(snapshot_dir)
597}
598
599pub fn list_snapshots(base_dir: &Path) -> AgentResult<Vec<SnapshotInfo>> {
603 let snapshots_dir = base_dir.join("snapshots");
604 if !snapshots_dir.exists() {
605 return Ok(Vec::new());
606 }
607
608 let mut snapshots = Vec::new();
609 for entry in std::fs::read_dir(&snapshots_dir)? {
610 let entry = entry?;
611 let path = entry.path();
612 if !path.is_dir() {
613 continue;
614 }
615
616 let info_path = path.join("snapshot_info.json");
617 if !info_path.exists() {
618 continue;
619 }
620
621 let content = std::fs::read_to_string(&info_path)?;
622 let info: serde_json::Value = serde_json::from_str(&content)?;
623
624 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
625 let session_id = info["session_id"].as_str().unwrap_or("-").to_string();
626 let created_at = info["created_at"].as_str().unwrap_or("-").to_string();
627 let turn_count = info["turn_count"].as_u64().unwrap_or(0) as u32;
628
629 snapshots.push(SnapshotInfo { name, session_id, created_at, turn_count, path });
630 }
631
632 snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
633 Ok(snapshots)
634}
635
636pub struct SnapshotInfo {
638 pub name: String,
640 pub session_id: String,
642 pub created_at: String,
644 pub turn_count: u32,
646 pub path: PathBuf,
648}
649
650pub fn restore_snapshot(name: &str, base_dir: &Path) -> AgentResult<SessionContext> {
654 validate_snapshot_name(name)?;
655 let snapshot_dir = base_dir.join("snapshots").join(name);
656 if !snapshot_dir.exists() {
657 return Err(AgentError::config_error(format!("Snapshot '{}' not found at {}", name, snapshot_dir.display())));
658 }
659
660 let new_session_id = generate_session_id();
662 let (new_session_dir, _) = get_or_create_session_dir(&new_session_id, base_dir)?;
663
664 for entry in std::fs::read_dir(&snapshot_dir)? {
666 let entry = entry?;
667 let fname = entry.file_name();
668 let name_str = fname.to_string_lossy();
669 if name_str.starts_with("turn_") && name_str.ends_with(".jsonl") {
670 std::fs::copy(entry.path(), new_session_dir.join(&*name_str))?;
671 }
672 }
673
674 let lock = acquire_session_lock(&new_session_dir)?;
675 tracing::info!(from = name, to = %new_session_id, "session restored from snapshot");
676
677 Ok(SessionContext {
678 session_id: new_session_id,
679 session_dir: new_session_dir,
680 base_dir: base_dir.to_path_buf(),
681 is_new_session: false,
682 _lock: Some(lock),
683 })
684}
685
686pub fn delete_snapshot(name: &str, base_dir: &Path) -> AgentResult<()> {
688 validate_snapshot_name(name)?;
689 let snapshot_dir = base_dir.join("snapshots").join(name);
690 if !snapshot_dir.exists() {
691 return Err(AgentError::config_error(format!("Snapshot '{}' not found", name)));
692 }
693 std::fs::remove_dir_all(&snapshot_dir)?;
694 tracing::info!(name = name, "snapshot deleted");
695 Ok(())
696}
697
698pub fn resolve_session(cli_session_id: Option<&str>, base_dir: &Path) -> AgentResult<SessionContext> {
703 let session_id = resolve_session_id(cli_session_id)?;
704 let (session_dir, is_new) = get_or_create_session_dir(&session_id, base_dir)?;
705 let lock = acquire_session_lock(&session_dir)?;
706
707 Ok(SessionContext {
708 session_id,
709 session_dir,
710 base_dir: base_dir.to_path_buf(),
711 is_new_session: is_new,
712 _lock: Some(lock),
713 })
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use tempfile::TempDir;
720
721 #[test]
722 fn test_validate_session_id_valid() {
723 assert!(validate_session_id("my-session-123").is_ok());
724 assert!(validate_session_id("test_456").is_ok());
725 assert!(validate_session_id("a").is_ok());
726 }
727
728 #[test]
729 fn test_validate_session_id_invalid() {
730 assert!(validate_session_id("").is_err());
731 assert!(validate_session_id("my session").is_err());
732 assert!(validate_session_id("../etc").is_err());
733 assert!(validate_session_id("path/traversal").is_err());
734 }
735
736 #[test]
737 fn test_generate_session_id() {
738 let id = generate_session_id();
739 assert!(id.contains('_'));
740 let parts: Vec<&str> = id.split('_').collect();
741 assert_eq!(parts.len(), 2);
742 assert_eq!(parts[0].len(), 8);
743 assert_eq!(parts[1].len(), 8);
744 }
745
746 #[test]
747 fn test_session_context_methods() {
748 let tmp = TempDir::new().unwrap();
749 let ctx = resolve_session(Some("test-ctx"), tmp.path()).unwrap();
750
751 assert_eq!(ctx.session_id, "test-ctx");
752 assert!(ctx.session_id_path().exists());
753 assert!(ctx.metadata_path().exists());
754 assert_eq!(ctx.log_path(), ctx.session_dir.join("session.log"));
755 assert_eq!(ctx.turn_path(1), ctx.session_dir.join("turn_001.jsonl"));
756 }
757
758 #[test]
759 fn test_last_turn_number_scans_existing_turns() {
760 let tmp = TempDir::new().unwrap();
761 let ctx = resolve_session(Some("turn-scan"), tmp.path()).unwrap();
762 assert_eq!(ctx.last_turn_number(), 0, "empty session should start at 0");
763
764 std::fs::write(ctx.turn_path(1), "").unwrap();
765 std::fs::write(ctx.turn_path(3), "").unwrap();
766 assert_eq!(ctx.last_turn_number(), 3);
767
768 std::fs::write(ctx.session_dir.join("session.log"), "").unwrap();
770 std::fs::write(ctx.session_dir.join("turn_abc.jsonl"), "").unwrap();
771 std::fs::write(ctx.session_dir.join("turn_004.jsonl"), "").unwrap();
772 assert_eq!(ctx.last_turn_number(), 4);
773 }
774
775 #[test]
776 fn test_cleanup_expired_sessions() {
777 let tmp = TempDir::new().unwrap();
778 let (dir, _) = get_or_create_session_dir("old-session", tmp.path()).unwrap();
779
780 let meta_path = dir.join("session_meta.json");
781 let mut meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
782 let old = (chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339();
783 meta["last_active_at"] = serde_json::json!(old);
784 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
785
786 get_or_create_session_dir("new-session", tmp.path()).unwrap();
787 let cleaned = cleanup_expired_sessions(tmp.path(), 7).unwrap();
788 assert_eq!(cleaned, 1);
789 assert!(!dir.exists());
790 }
791
792 #[test]
795 fn test_validate_session_id_returns_config_error() {
796 let err = validate_session_id("").unwrap_err();
798 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for empty session ID, got {:?}", err);
799
800 let err = validate_session_id("../etc").unwrap_err();
802 assert!(
803 matches!(err, AgentError::ConfigError(_)),
804 "expected ConfigError for invalid session ID, got {:?}",
805 err
806 );
807 }
808
809 #[test]
810 fn test_resolve_session_returns_config_error_for_invalid_id() {
811 let tmp = TempDir::new().unwrap();
812 let result = resolve_session(Some("bad id with spaces"), tmp.path());
813 assert!(result.is_err());
814 let err = result.err().unwrap();
815 assert!(
816 matches!(err, AgentError::ConfigError(_)),
817 "expected ConfigError for invalid session ID, got {:?}",
818 err
819 );
820 }
821
822 #[test]
823 fn test_acquire_session_lock_returns_resource_unavailable_when_locked() {
824 let tmp = TempDir::new().unwrap();
825 let (_dir, _) = get_or_create_session_dir("lock-test", tmp.path()).unwrap();
826 let session_dir = tmp.path().join("sessions").join("lock-test");
827
828 let _lock1 = acquire_session_lock(&session_dir).unwrap();
830
831 let err = acquire_session_lock(&session_dir).unwrap_err();
833 assert!(
834 matches!(err, AgentError::ResourceUnavailable(_)),
835 "expected ResourceUnavailable for locked session, got {:?}",
836 err
837 );
838 }
839
840 #[test]
843 fn test_validate_snapshot_name_valid() {
844 assert!(validate_snapshot_name("my-snapshot").is_ok());
845 assert!(validate_snapshot_name("test_123").is_ok());
846 assert!(validate_snapshot_name("a").is_ok());
847 assert!(validate_snapshot_name("snapshot-2024-01-01").is_ok());
848 }
849
850 #[test]
851 fn test_validate_snapshot_name_invalid() {
852 assert!(validate_snapshot_name("").is_err());
853 assert!(validate_snapshot_name("../etc").is_err());
854 assert!(validate_snapshot_name("path/traversal").is_err());
855 assert!(validate_snapshot_name("back\\slash").is_err());
856 assert!(validate_snapshot_name("dot..dot").is_err());
857 assert!(validate_snapshot_name("has space").is_err());
858 assert!(validate_snapshot_name(&"x".repeat(65)).is_err()); }
860
861 #[test]
862 fn test_validate_snapshot_name_returns_config_error() {
863 let err = validate_snapshot_name("").unwrap_err();
864 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError, got {:?}", err);
865
866 let err = validate_snapshot_name("../etc").unwrap_err();
867 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for path traversal, got {:?}", err);
868 }
869
870 #[test]
873 fn test_create_snapshot_and_list() {
874 let tmp = TempDir::new().unwrap();
875 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
876
877 let turn_path = ctx.turn_path(1);
879 std::fs::write(&turn_path, "{}").unwrap();
880
881 let snap_path = create_snapshot(&ctx, "test-snap", tmp.path()).unwrap();
883 assert!(snap_path.exists());
884 assert!(snap_path.join("snapshot_info.json").exists());
885 assert!(snap_path.join("turn_001.jsonl").exists());
886
887 let snaps = list_snapshots(tmp.path()).unwrap();
889 assert_eq!(snaps.len(), 1);
890 assert_eq!(snaps[0].name, "test-snap");
891 assert_eq!(snaps[0].session_id, "snap-session");
892 assert_eq!(snaps[0].turn_count, 1);
893 }
894
895 #[test]
896 fn test_create_snapshot_overwrites_existing() {
897 let tmp = TempDir::new().unwrap();
898 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
899
900 create_snapshot(&ctx, "dup-snap", tmp.path()).unwrap();
901 let result = create_snapshot(&ctx, "dup-snap", tmp.path());
903 assert!(result.is_ok());
904
905 let snaps = list_snapshots(tmp.path()).unwrap();
906 assert_eq!(snaps.len(), 1);
907 }
908
909 #[test]
910 fn test_list_snapshots_empty_dir() {
911 let tmp = TempDir::new().unwrap();
912 let snaps = list_snapshots(tmp.path()).unwrap();
913 assert!(snaps.is_empty());
914 }
915
916 #[test]
917 fn test_list_snapshots_sorted_by_date_desc() {
918 let tmp = TempDir::new().unwrap();
919 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
920
921 create_snapshot(&ctx, "first", tmp.path()).unwrap();
922 std::thread::sleep(std::time::Duration::from_millis(10));
923 create_snapshot(&ctx, "second", tmp.path()).unwrap();
924
925 let snaps = list_snapshots(tmp.path()).unwrap();
926 assert_eq!(snaps.len(), 2);
927 assert_eq!(snaps[0].name, "second");
929 assert_eq!(snaps[1].name, "first");
930 }
931
932 #[test]
935 fn test_restore_snapshot_success() {
936 let tmp = TempDir::new().unwrap();
937 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
938
939 std::fs::write(ctx.turn_path(1), r#"{"type":"text_delta","text":"hello"}"#).unwrap();
941 create_snapshot(&ctx, "restore-me", tmp.path()).unwrap();
942
943 let restored = restore_snapshot("restore-me", tmp.path()).unwrap();
945 assert_ne!(restored.session_id, "snap-session");
946 assert!(!restored.is_new_session);
947 assert!(restored.turn_path(1).exists());
948
949 let content = std::fs::read_to_string(restored.turn_path(1)).unwrap();
951 assert!(content.contains("hello"));
952 }
953
954 #[test]
955 fn test_restore_snapshot_not_found() {
956 let tmp = TempDir::new().unwrap();
957 let err = restore_snapshot("no-such-snapshot", tmp.path()).unwrap_err();
958 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for missing snapshot, got {:?}", err);
959 }
960
961 #[test]
964 fn test_delete_snapshot_success() {
965 let tmp = TempDir::new().unwrap();
966 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
967
968 create_snapshot(&ctx, "del-me", tmp.path()).unwrap();
969 assert_eq!(list_snapshots(tmp.path()).unwrap().len(), 1);
970
971 delete_snapshot("del-me", tmp.path()).unwrap();
972 assert_eq!(list_snapshots(tmp.path()).unwrap().len(), 0);
973 }
974
975 #[test]
976 fn test_delete_snapshot_not_found() {
977 let tmp = TempDir::new().unwrap();
978 let err = delete_snapshot("no-such-snapshot", tmp.path()).unwrap_err();
979 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for missing snapshot, got {:?}", err);
980 }
981
982 #[test]
985 fn test_session_context_stores_base_dir() {
986 let tmp = TempDir::new().unwrap();
987 let ctx = resolve_session(Some("base-test"), tmp.path()).unwrap();
988 assert_eq!(ctx.base_dir, tmp.path());
989 assert!(ctx.session_dir.starts_with(&ctx.base_dir));
990 }
991
992 #[test]
993 fn test_session_context_messages_jsonl_path() {
994 let tmp = TempDir::new().unwrap();
995 let ctx = resolve_session(Some("mj-path"), tmp.path()).unwrap();
996 assert_eq!(ctx.messages_jsonl_path(), ctx.session_dir.join("messages.jsonl"));
997 }
998
999 fn sample_messages() -> Vec<ChatMessage> {
1003 vec![
1004 ChatMessage::User { content: "hello".to_string(), images: vec![], ephemeral: false },
1005 ChatMessage::Assistant {
1006 content: Some("hi there".to_string()),
1007 reasoning_content: Some("thinking...".to_string()),
1008 thinking_signature: Some("sig123".to_string()),
1009 tool_calls: None,
1010 },
1011 ]
1012 }
1013
1014 #[test]
1015 fn test_persist_and_load_roundtrip() {
1016 let tmp = TempDir::new().unwrap();
1017 let dir = tmp.path();
1018
1019 let msgs = sample_messages();
1020 persist_window_messages(dir, &msgs).unwrap();
1021
1022 let loaded = load_session_messages(dir).unwrap();
1023 assert_eq!(loaded.len(), 2);
1024
1025 assert!(matches!(&loaded[0], ChatMessage::User { content, .. } if content == "hello"));
1027
1028 match &loaded[1] {
1030 ChatMessage::Assistant { content, reasoning_content, thinking_signature, tool_calls } => {
1031 assert_eq!(content.as_deref(), Some("hi there"));
1032 assert!(reasoning_content.is_none(), "reasoning_content should be stripped");
1033 assert!(thinking_signature.is_none(), "thinking_signature should be stripped");
1034 assert!(tool_calls.is_none());
1035 },
1036 other => panic!("expected Assistant, got {:?}", other),
1037 }
1038 }
1039
1040 #[test]
1041 fn test_persist_skips_system_and_ephemeral() {
1042 let tmp = TempDir::new().unwrap();
1043 let dir = tmp.path();
1044
1045 let msgs = vec![
1046 ChatMessage::System { content: "system prompt".to_string(), ephemeral: false },
1047 ChatMessage::User { content: "ephemeral ask".to_string(), images: vec![], ephemeral: true },
1048 ChatMessage::User { content: "real question".to_string(), images: vec![], ephemeral: false },
1049 ];
1050 persist_window_messages(dir, &msgs).unwrap();
1051
1052 let loaded = load_session_messages(dir).unwrap();
1053 assert_eq!(loaded.len(), 1, "System + ephemeral User should be filtered");
1054 assert!(matches!(&loaded[0], ChatMessage::User { content, .. } if content == "real question"));
1055 }
1056
1057 #[test]
1058 fn test_load_skips_malformed_lines() {
1059 let tmp = TempDir::new().unwrap();
1060 let dir = tmp.path();
1061 let path = dir.join("messages.jsonl");
1062
1063 let mut file = File::create(&path).unwrap();
1065 writeln!(file, r#"{{"User":{{"content":"good","images":[]}}}}"#).unwrap();
1066 writeln!(file, "NOT VALID JSON").unwrap();
1067 writeln!(file).unwrap(); writeln!(file, r#"{{"User":{{"content":"also good","images":[]}}}}"#).unwrap();
1069
1070 let loaded = load_session_messages(dir).unwrap();
1071 assert_eq!(loaded.len(), 2, "malformed + empty lines should be skipped");
1072 }
1073
1074 #[test]
1075 fn test_load_patches_dangling_tool_calls() {
1076 use agent_base::llm_trait::ToolCallMessage;
1077
1078 let tmp = TempDir::new().unwrap();
1079 let dir = tmp.path();
1080
1081 let msgs = vec![
1083 ChatMessage::User { content: "run tools".to_string(), images: vec![], ephemeral: false },
1084 ChatMessage::Assistant {
1085 content: None,
1086 reasoning_content: None,
1087 thinking_signature: None,
1088 tool_calls: Some(vec![
1089 ToolCallMessage {
1090 id: "tc_answered".to_string(),
1091 name: "read_file".to_string(),
1092 arguments: "{}".to_string(),
1093 },
1094 ToolCallMessage {
1095 id: "tc_dangling".to_string(),
1096 name: "write_file".to_string(),
1097 arguments: "{}".to_string(),
1098 },
1099 ]),
1100 },
1101 ChatMessage::Tool {
1102 tool_call_id: "tc_answered".to_string(),
1103 name: Some("read_file".to_string()),
1104 content: "file content".to_string(),
1105 },
1106 ];
1108 persist_window_messages(dir, &msgs).unwrap();
1109
1110 let loaded = load_session_messages(dir).unwrap();
1111 assert_eq!(loaded.len(), 4, "expected 4 messages including patched Tool");
1113
1114 match &loaded[3] {
1115 ChatMessage::Tool { tool_call_id, content, .. } => {
1116 assert_eq!(tool_call_id, "tc_dangling");
1117 assert_eq!(content, "interrupted");
1118 },
1119 other => panic!("expected patched Tool message, got {:?}", other),
1120 }
1121 }
1122
1123 #[test]
1124 fn test_persist_atomic_crash_preserves_old_file() {
1125 let tmp = TempDir::new().unwrap();
1126 let dir = tmp.path();
1127
1128 let original = vec![ChatMessage::User { content: "original".to_string(), images: vec![], ephemeral: false }];
1130 persist_window_messages(dir, &original).unwrap();
1131
1132 let path = messages_jsonl_path(dir);
1134 let tmp_path = path.with_extension("jsonl.tmp");
1135 std::fs::write(&tmp_path, "TRUNCATED").unwrap();
1136
1137 let loaded = load_session_messages(dir).unwrap();
1139 assert_eq!(loaded.len(), 1);
1140 assert!(matches!(&loaded[0], ChatMessage::User { content, .. } if content == "original"));
1141 }
1142
1143 #[test]
1144 fn test_clear_messages_jsonl() {
1145 let tmp = TempDir::new().unwrap();
1146 let dir = tmp.path();
1147
1148 persist_window_messages(dir, &sample_messages()).unwrap();
1149 assert!(messages_jsonl_path(dir).exists());
1150
1151 clear_messages_jsonl(dir).unwrap();
1152 assert!(!messages_jsonl_path(dir).exists());
1153 }
1154
1155 #[test]
1156 fn test_clear_messages_jsonl_noop_when_missing() {
1157 let tmp = TempDir::new().unwrap();
1158 clear_messages_jsonl(tmp.path()).unwrap();
1160 }
1161
1162 #[test]
1163 fn test_load_returns_empty_when_no_file() {
1164 let tmp = TempDir::new().unwrap();
1165 let loaded = load_session_messages(tmp.path()).unwrap();
1166 assert!(loaded.is_empty());
1167 }
1168
1169 #[test]
1170 fn test_persist_empty_messages() {
1171 let tmp = TempDir::new().unwrap();
1172 persist_window_messages(tmp.path(), &[]).unwrap();
1173
1174 let loaded = load_session_messages(tmp.path()).unwrap();
1175 assert!(loaded.is_empty());
1176 }
1177
1178 #[test]
1179 fn test_load_strips_reasoning_on_all_assistants() {
1180 use agent_base::llm_trait::ToolCallMessage;
1181
1182 let tmp = TempDir::new().unwrap();
1183 let dir = tmp.path();
1184
1185 let msgs = vec![
1186 ChatMessage::Assistant {
1187 content: Some("first".to_string()),
1188 reasoning_content: Some("r1".to_string()),
1189 thinking_signature: Some("s1".to_string()),
1190 tool_calls: Some(vec![ToolCallMessage {
1191 id: "tc1".to_string(),
1192 name: "tool".to_string(),
1193 arguments: "{}".to_string(),
1194 }]),
1195 },
1196 ChatMessage::Tool {
1197 tool_call_id: "tc1".to_string(),
1198 name: Some("tool".to_string()),
1199 content: "ok".to_string(),
1200 },
1201 ChatMessage::Assistant {
1202 content: Some("second".to_string()),
1203 reasoning_content: Some("r2".to_string()),
1204 thinking_signature: Some("s2".to_string()),
1205 tool_calls: None,
1206 },
1207 ];
1208 persist_window_messages(dir, &msgs).unwrap();
1209
1210 let loaded = load_session_messages(dir).unwrap();
1211 for msg in &loaded {
1212 if let ChatMessage::Assistant { reasoning_content, thinking_signature, .. } = msg {
1213 assert!(reasoning_content.is_none(), "reasoning_content should be stripped");
1214 assert!(thinking_signature.is_none(), "thinking_signature should be stripped");
1215 }
1216 }
1217 }
1218
1219 #[test]
1222 fn extract_title_from_messages_jsonl() {
1223 let tmp = TempDir::new().unwrap();
1224 let dir = tmp.path().join("s1");
1225 std::fs::create_dir_all(&dir).unwrap();
1226 let msg = serde_json::json!({"User":{"content":"帮我写个函数","images":[]}});
1227 std::fs::write(dir.join("messages.jsonl"), format!("{}\n", msg)).unwrap();
1228 assert_eq!(extract_session_title(&dir), "帮我写个函数");
1229 }
1230
1231 #[test]
1232 fn extract_title_truncates_long_content() {
1233 let tmp = TempDir::new().unwrap();
1234 let dir = tmp.path().join("s1");
1235 std::fs::create_dir_all(&dir).unwrap();
1236 let long_msg = "a".repeat(100);
1237 let msg = serde_json::json!({"User":{"content":long_msg,"images":[]}});
1238 std::fs::write(dir.join("messages.jsonl"), format!("{}\n", msg)).unwrap();
1239 let title = extract_session_title(&dir);
1240 assert!(title.ends_with("..."), "expected truncation, got: {}", title);
1241 }
1242
1243 #[test]
1244 fn extract_title_fallback_to_turn_log() {
1245 let tmp = TempDir::new().unwrap();
1246 let dir = tmp.path().join("fallback");
1247 std::fs::create_dir_all(&dir).unwrap();
1248 let sys = serde_json::json!({"System":{"content":"sys"}});
1249 std::fs::write(dir.join("messages.jsonl"), format!("{}\n", sys)).unwrap();
1250 let turn = serde_json::json!({"turn":1,"timestamp":"2026-09-10T10:00:00Z","user_input":"from turn log"});
1251 std::fs::write(dir.join("turn_001.jsonl"), format!("{}\n", turn)).unwrap();
1252 assert_eq!(extract_session_title(&dir), "from turn log");
1253 }
1254
1255 #[test]
1256 fn extract_title_empty_when_nothing() {
1257 let tmp = TempDir::new().unwrap();
1258 assert_eq!(extract_session_title(tmp.path()), "");
1259 }
1260
1261 #[test]
1264 fn list_sessions_finds_resumable() {
1265 let tmp = TempDir::new().unwrap();
1266 for id in &["s1", "s2"] {
1267 let dir = tmp.path().join("sessions").join(id);
1268 std::fs::create_dir_all(&dir).unwrap();
1269 std::fs::write(dir.join("session_meta.json"), "{}").unwrap();
1270 let msg = serde_json::json!({"User":{"content":"hello","images":[]}});
1271 std::fs::write(dir.join("messages.jsonl"), format!("{}\n", msg)).unwrap();
1272 }
1273 let entries = list_sessions(tmp.path(), None);
1274 assert_eq!(entries.len(), 2);
1275 }
1276
1277 #[test]
1278 fn list_sessions_skips_current() {
1279 let tmp = TempDir::new().unwrap();
1280 let dir = tmp.path().join("sessions").join("cur");
1281 std::fs::create_dir_all(&dir).unwrap();
1282 std::fs::write(dir.join("session_meta.json"), "{}").unwrap();
1283 let msg = serde_json::json!({"User":{"content":"hello","images":[]}});
1284 std::fs::write(dir.join("messages.jsonl"), format!("{}\n", msg)).unwrap();
1285 assert_eq!(list_sessions(tmp.path(), Some("cur")).len(), 0);
1286 }
1287}
1288
1289#[cfg(test)]
1290mod proptests {
1291 use super::*;
1292
1293 proptest::proptest! {
1294 #[test]
1295 fn validate_session_id_never_panics(s in ".*") {
1296 let _ = validate_session_id(&s);
1297 }
1298
1299 #[test]
1300 fn validate_session_id_accepts_valid_chars_only(s in "[a-zA-Z0-9_-]{1,128}") {
1301 proptest::prop_assert!(validate_session_id(&s).is_ok(), "valid session_id '{}' should pass", s);
1302 }
1303
1304 #[test]
1305 fn validate_snapshot_name_rejects_path_traversal(s in ".*") {
1306 let result = validate_snapshot_name(&s);
1307 if s.contains('/') || s.contains('\\') || s.contains("..") {
1308 proptest::prop_assert!(result.is_err(), "snapshot name '{}' with path traversal should fail", s);
1309 }
1310 }
1311
1312 #[test]
1313 fn cleanup_timestamp_rfc3339_never_panics(ts in "[0-9T:+ -Zz.]{1,40}") {
1314 let _ = chrono::DateTime::parse_from_rfc3339(&ts);
1315 }
1316 }
1317}