Skip to main content

phi_agent/
session.rs

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/// Session context — holds the session ID, directory, base directory, and file lock.
15///
16/// Created via [`resolve_session`]. The lock is released when the struct is dropped.
17#[derive(Debug)]
18pub struct SessionContext {
19    /// Session identifier (user-provided or auto-generated).
20    pub session_id: String,
21    /// Path to the session directory on disk.
22    pub session_dir: PathBuf,
23    /// Base directory for all session data.
24    pub base_dir: PathBuf,
25    /// Whether this session was just created (vs reused).
26    pub is_new_session: bool,
27    _lock: Option<File>,
28}
29
30impl SessionContext {
31    /// Path to the session ID marker file.
32    #[allow(dead_code)]
33    pub fn session_id_path(&self) -> PathBuf {
34        self.session_dir.join("session_id")
35    }
36
37    /// Path to the session metadata JSON file.
38    pub fn metadata_path(&self) -> PathBuf {
39        self.session_dir.join("session_meta.json")
40    }
41
42    /// Path to the session log file (human-readable).
43    pub fn log_path(&self) -> PathBuf {
44        self.session_dir.join("session.log")
45    }
46
47    /// Path to the per-turn JSONL event log. `turn` is 1-indexed.
48    pub fn turn_path(&self, turn: usize) -> PathBuf {
49        self.session_dir.join(format!("turn_{:03}.jsonl", turn))
50    }
51
52    /// Path to the `messages.jsonl` window snapshot (one `ChatMessage` per line).
53    pub fn messages_jsonl_path(&self) -> PathBuf {
54        self.session_dir.join("messages.jsonl")
55    }
56
57    /// Highest turn number already logged for this session (0 if none).
58    ///
59    /// Scans `session_dir` for `turn_NNN.jsonl` files and returns the max `N`.
60    /// Consumers that persist per-turn logs should initialize their turn
61    /// counter from this value (then increment) so that reusing a session id
62    /// across process runs *continues* the turn sequence instead of resetting
63    /// to 1 and appending a later run's `turn_001.jsonl` into an earlier one.
64    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
82/// Validate session ID format.
83///
84/// Allowed: alphanumerics, hyphens, underscores. 1–128 characters.
85pub 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
100/// Validate snapshot name format.
101///
102/// Allowed: alphanumerics, hyphens, underscores. 1–64 characters.
103/// No path separators or traversal sequences allowed.
104pub 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
126/// Resolve session ID (priority: CLI arg → `PHI_SESSION_ID` env var → auto-generate).
127pub 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
143/// Generate a new session_id (format: YYYYMMDD_first8ofUuid)
144pub 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
151/// Get or create a session directory under `base_dir/sessions/<session_id>`.
152///
153/// Returns the directory path and whether it was newly created.
154pub 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    // Write session_id file
166    std::fs::write(session_dir.join("session_id"), session_id)?;
167
168    // Update session_meta.json
169    update_session_meta(&session_dir, session_id)?;
170
171    Ok((session_dir, is_new))
172}
173
174/// Acquire an exclusive file lock on the session directory.
175///
176/// Prevents concurrent access from other processes. Returns an error if the
177/// session is already in use.
178pub 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
192/// Update session_meta.json
193fn 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
212/// Read the LLM-generated title from `session_meta.json`.
213///
214/// Returns `None` if the file is missing, malformed, or has no `title` field.
215pub 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
222/// Write a session title into `session_meta.json`.
223///
224/// `generated = true` means the title was produced by LLM at the 10th-user-message
225/// threshold and should NOT be regenerated on subsequent exits.
226/// `generated = false` means it's a tentative title (e.g., produced on early exit)
227/// that will be regenerated if the session continues to 10 user messages.
228pub 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
244/// Clean up expired sessions.
245///
246/// Sessions inactive for more than `max_age_days` are removed from disk.
247/// Active (locked) are skipped.
248pub 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; // locked, skip
271        }
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// ── session listing (for resume picker / session browser) ──
303
304/// A session entry returned by [`list_sessions`].
305#[derive(Debug)]
306pub struct SessionInfo {
307    /// Session ID (directory name, e.g. `"20260914_50cf809d"`).
308    pub session_id: String,
309    /// Display title (LLM-generated, first User message, or empty).
310    pub title: String,
311    /// Last activity time (`messages.jsonl` mtime).
312    pub last_active_at: SystemTime,
313    /// Path to the session directory.
314    pub session_dir: PathBuf,
315}
316
317/// List resumable sessions under `base_dir/sessions/`.
318///
319/// Scans session directories, filters out locked / empty / current sessions,
320/// and returns entries sorted by `messages.jsonl` mtime descending (newest first).
321/// Any framework consumer can use this to present a session history list.
322pub 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        // Skip the current session.
344        if Some(session_id.as_str()) == current_session_id {
345            continue;
346        }
347
348        // Skip sessions that are locked by another running process.
349        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        // messages.jsonl must exist and contain at least one message.
358        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
377/// Extract a display title for a session.
378///
379/// Priority: LLM-generated title → first User message → turn_001 user_input → empty.
380fn extract_session_title(session_dir: &Path) -> String {
381    // 1. LLM-generated title from session_meta.json.
382    if let Some(title) = read_session_title(session_dir) {
383        return title;
384    }
385
386    // 2. First User message from messages.jsonl.
387    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    // 3. turn_001.jsonl user_input field.
403    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
418/// Truncate display text to `max` chars, appending "..." if truncated.
419fn 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
429// ── messages.jsonl persistence (resume support) ──
430
431/// Path to the `messages.jsonl` file inside a session directory.
432fn messages_jsonl_path(session_dir: &Path) -> PathBuf {
433    session_dir.join("messages.jsonl")
434}
435
436/// Strip stale reasoning metadata from an Assistant message so that a resumed
437/// conversation never replays stale `reasoning_content` / `thinking_signature`.
438fn 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
445/// Persist the current window's full `ChatMessage` list as a JSONL snapshot.
446///
447/// Writes atomically (tmp → rename) so a crash mid-write never corrupts the
448/// existing file.  System and ephemeral messages are skipped — the resumed
449/// session will use a fresh system prompt.
450pub 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
472/// Load a previously-persisted `messages.jsonl` and sanitize for resume.
473///
474/// Sanitization:
475/// - Skip empty lines and malformed JSON (logged + skipped, not fatal).
476/// - Drop any `System` messages (the resumed session uses a fresh system prompt).
477/// - Strip `reasoning_content` / `thinking_signature` from Assistant messages.
478/// - Patch dangling tool calls at the tail: if the last Assistant message has
479///   `tool_calls` that were never answered, synthesize a `Tool` response with
480///   content `"interrupted"` so that `validate_message_sequence` passes.
481pub 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        // Drop System messages — resume prepends a fresh system prompt.
504        if matches!(msg, ChatMessage::System { .. }) {
505            continue;
506        }
507        strip_stale_fields(&mut msg);
508        messages.push(msg);
509    }
510
511    // Patch dangling tool calls at the tail.  A cancelled turn may leave an
512    // Assistant with `tool_calls` but no matching Tool response — synthesize
513    // an "interrupted" Tool message so `validate_message_sequence` passes.
514    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
534/// Delete the `messages.jsonl` file if it exists.
535///
536/// Called after a context-rotation window is archived — the archived window
537/// already contains the messages, so keeping `messages.jsonl` would be
538/// redundant and could cause double-injection on resume.
539pub 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
547// ── Session snapshots (Phase 6.3) ──
548
549/// Create a named snapshot of a session.
550///
551/// Copies the session directory to `base_dir/snapshots/<name>/`.
552/// Returns the snapshot path.
553pub 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    // Copy session metadata
563    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    // Copy all turn logs
569    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    // Write snapshot info
581    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
599/// List all saved snapshots under `base_dir/snapshots/`.
600///
601/// Returns a vector of (name, snapshot_info_json, turn_count).
602pub 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
636/// Info about a saved snapshot.
637pub struct SnapshotInfo {
638    /// Snapshot name.
639    pub name: String,
640    /// Original session ID this snapshot was taken from.
641    pub session_id: String,
642    /// ISO-8601 timestamp of when the snapshot was created.
643    pub created_at: String,
644    /// Number of turns captured in the snapshot.
645    pub turn_count: u32,
646    /// Path to the snapshot directory on disk.
647    pub path: PathBuf,
648}
649
650/// Restore a session from a snapshot.
651///
652/// Creates a new session from the snapshot data. Returns the new session context.
653pub 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    // Generate new session ID and directory
661    let new_session_id = generate_session_id();
662    let (new_session_dir, _) = get_or_create_session_dir(&new_session_id, base_dir)?;
663
664    // Copy turn logs from snapshot
665    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
686/// Delete a saved snapshot.
687pub 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
698/// Resolve and create a session context — session ID, directory, and file lock.
699///
700/// This is the primary entry point for session setup. It combines ID resolution,
701/// directory creation, and lock acquisition into a single call.
702pub 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        // Non-turn files (and malformed turn names) are ignored.
769        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    // ── Phase 1: AgentError contract tests ──
793
794    #[test]
795    fn test_validate_session_id_returns_config_error() {
796        // Empty ID
797        let err = validate_session_id("").unwrap_err();
798        assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for empty session ID, got {:?}", err);
799
800        // Invalid characters
801        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        // First lock succeeds
829        let _lock1 = acquire_session_lock(&session_dir).unwrap();
830
831        // Second lock should fail with ResourceUnavailable
832        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    // ── Snapshot name validation tests ──
841
842    #[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()); // too long
859    }
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    // ── Snapshot create / list tests ──
871
872    #[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        // Have at least one turn log so we can verify it is copied
878        let turn_path = ctx.turn_path(1);
879        std::fs::write(&turn_path, "{}").unwrap();
880
881        // Create a snapshot
882        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        // List — should contain exactly one
888        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        // Second create with same name should succeed (overwrite)
902        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        // Most recent first
928        assert_eq!(snaps[0].name, "second");
929        assert_eq!(snaps[1].name, "first");
930    }
931
932    // ── Snapshot restore tests ──
933
934    #[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        // Write a turn log
940        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        // Restore into a new session
944        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        // Verify turn log content was copied
950        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    // ── Snapshot delete tests ──
962
963    #[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    // ── SessionContext base_dir test ──
983
984    #[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    // ── Phase 1: messages.jsonl persistence tests ──
1000
1001    /// Build a minimal ChatMessage list for testing (no System messages).
1002    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        // User message round-trips.
1026        assert!(matches!(&loaded[0], ChatMessage::User { content, .. } if content == "hello"));
1027
1028        // Assistant: reasoning_content and thinking_signature are stripped.
1029        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        // Mix valid and invalid lines.
1064        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(); // empty line
1068        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        // Assistant with two tool_calls, only one answered.
1082        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            // tc_dangling has no response — simulates a cancelled turn.
1107        ];
1108        persist_window_messages(dir, &msgs).unwrap();
1109
1110        let loaded = load_session_messages(dir).unwrap();
1111        // Should have: User, Assistant, Tool(answered), Tool(interrupted patch).
1112        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        // Write an initial valid file.
1129        let original = vec![ChatMessage::User { content: "original".to_string(), images: vec![], ephemeral: false }];
1130        persist_window_messages(dir, &original).unwrap();
1131
1132        // Simulate a partial write by writing a truncated tmp and NOT renaming.
1133        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        // The real file should still be intact.
1138        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        // Should not error when the file doesn't exist.
1159        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    // ── extract_session_title tests ──
1220
1221    #[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    // ── list_sessions tests ──
1262
1263    #[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}