Skip to main content

zagens_runtime_adapters/persist/
session_manager.rs

1//! Session management for resuming conversations.
2//!
3//! This module provides functionality for:
4//! - Saving sessions to disk
5//! - Listing previous sessions
6//! - Resuming sessions by ID
7//! - Managing session lifecycle
8
9use crate::models::{ContentBlock, Message, SystemPrompt};
10use crate::persist::context_reference::ContextReference;
11use crate::util::write_atomic;
12use chrono::{DateTime, Utc};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::fs;
16use std::path::{Path, PathBuf};
17use uuid::Uuid;
18
19/// Maximum number of sessions to retain
20const MAX_SESSIONS: usize = 50;
21/// Maximum number of messages to persist per session (#402 P0).
22/// Beyond this limit, the oldest messages are dropped and a truncation
23/// note is prepended to the system prompt. Keeps session files bounded
24/// so save/load remains fast even for long-running conversations.
25const MAX_PERSISTED_MESSAGES: usize = 500;
26const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
27const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
28/// Maximum session file size in bytes (default 5 MB). Sessions larger
29/// than this may cause large serde allocations and block the runtime.
30/// Override with `DEEPSEEK_MAX_SESSION_FILE_MB` env var (0 = no limit).
31const DEFAULT_MAX_SESSION_FILE_SIZE: u64 = 5 * 1024 * 1024;
32
33fn max_session_file_size() -> u64 {
34    // 1. Env var (highest precedence for backward compat)
35    if let Ok(mb_str) = std::env::var("DEEPSEEK_MAX_SESSION_FILE_MB")
36        && let Ok(mb) = mb_str.trim().parse::<u64>()
37    {
38        return if mb > 0 { mb * 1024 * 1024 } else { u64::MAX };
39    }
40    // 2. TOML [session] max_file_mb via Config struct
41    if let Ok(config_str) =
42        std::fs::read_to_string(zagens_config::default_config_path().unwrap_or_else(|_| {
43            dirs::home_dir()
44                .unwrap_or_default()
45                .join(zagens_config::USER_DATA_DIR_NAME)
46                .join("config.toml")
47        }))
48        && let Ok(config) = toml::from_str::<zagens_config::ConfigToml>(&config_str)
49    {
50        let mb = config.session.as_ref().map(|s| s.max_file_mb).unwrap_or(5);
51        return if mb > 0 { mb * 1024 * 1024 } else { u64::MAX };
52    }
53    DEFAULT_MAX_SESSION_FILE_SIZE
54}
55
56const fn default_session_schema_version() -> u32 {
57    CURRENT_SESSION_SCHEMA_VERSION
58}
59
60const fn default_queue_schema_version() -> u32 {
61    CURRENT_QUEUE_SCHEMA_VERSION
62}
63
64/// Persisted queued message for offline/degraded mode.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct QueuedSessionMessage {
67    pub display: String,
68    #[serde(default)]
69    pub skill_instruction: Option<String>,
70}
71
72/// Persisted queue state for recovery after restart/crash.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct OfflineQueueState {
75    #[serde(default = "default_queue_schema_version")]
76    pub schema_version: u32,
77    /// Session ID this queue belongs to. Queue is only restored when
78    /// resuming the same session to prevent stale messages leaking into new chats.
79    #[serde(default)]
80    pub session_id: Option<String>,
81    #[serde(default)]
82    pub messages: Vec<QueuedSessionMessage>,
83    #[serde(default)]
84    pub draft: Option<QueuedSessionMessage>,
85}
86
87impl Default for OfflineQueueState {
88    fn default() -> Self {
89        Self {
90            schema_version: CURRENT_QUEUE_SCHEMA_VERSION,
91            session_id: None,
92            messages: Vec::new(),
93            draft: None,
94        }
95    }
96}
97
98/// Durable context-reference metadata attached to a user message.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SessionContextReference {
101    pub message_index: usize,
102    pub reference: ContextReference,
103}
104
105/// Session metadata stored with each saved session
106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
107pub struct SessionMetadata {
108    /// Unique session identifier
109    pub id: String,
110    /// Human-readable title (derived from first message)
111    pub title: String,
112    /// When the session was created
113    pub created_at: DateTime<Utc>,
114    /// When the session was last updated
115    pub updated_at: DateTime<Utc>,
116    /// Number of messages in the session
117    pub message_count: usize,
118    /// Total tokens used
119    pub total_tokens: u64,
120    /// Model used for the session
121    pub model: String,
122    /// Workspace directory
123    #[schemars(schema_with = "crate::json_schema_util::path_as_string")]
124    pub workspace: PathBuf,
125    /// Optional mode label (agent/plan/etc.)
126    #[serde(default)]
127    pub mode: Option<String>,
128    /// Runtime thread store id for Zagens event replay (tools + thinking UI).
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub runtime_thread_id: Option<String>,
131}
132
133/// A saved session containing full conversation history
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SavedSession {
136    /// Schema version for migration compatibility
137    #[serde(default = "default_session_schema_version")]
138    pub schema_version: u32,
139    /// Session metadata
140    pub metadata: SessionMetadata,
141    /// Conversation messages
142    pub messages: Vec<Message>,
143    /// System prompt if any
144    pub system_prompt: Option<String>,
145    /// Compact linked context references for user-visible `@path` and
146    /// `/attach` mentions. Optional for backward-compatible session loads.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub context_references: Vec<SessionContextReference>,
149}
150
151/// Manager for session persistence operations
152pub struct SessionManager {
153    /// Directory where sessions are stored
154    sessions_dir: PathBuf,
155    /// SQLite connection (None = fallback to JSON-per-file)
156    db: Option<std::sync::Mutex<rusqlite::Connection>>,
157}
158
159impl SessionManager {
160    fn validated_session_path(&self, id: &str) -> std::io::Result<PathBuf> {
161        let trimmed = id.trim();
162        if trimmed.is_empty() {
163            return Err(std::io::Error::new(
164                std::io::ErrorKind::InvalidInput,
165                "Session id cannot be empty",
166            ));
167        }
168        if !trimmed
169            .chars()
170            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
171        {
172            return Err(std::io::Error::new(
173                std::io::ErrorKind::InvalidInput,
174                format!("Invalid session id '{id}'"),
175            ));
176        }
177        Ok(self.sessions_dir.join(format!("{trimmed}.json")))
178    }
179
180    /// Create a new `SessionManager` with the specified sessions directory.
181    /// Tries to open SQLite DB at `sessions_dir/sessions.db` with auto-migration
182    /// from JSON files if present.
183    pub fn new(sessions_dir: PathBuf) -> std::io::Result<Self> {
184        fs::create_dir_all(&sessions_dir)?;
185        let db_path = sessions_dir.join("sessions.db");
186        let db =
187            crate::persist::session_store_sqlite::open_sqlite_session_db(&db_path, &sessions_dir)
188                .ok();
189        Ok(Self {
190            sessions_dir,
191            db: db.map(std::sync::Mutex::new),
192        })
193    }
194
195    /// JSON-per-file sessions only (unit tests that write fixture `.json` files).
196    #[cfg(test)]
197    pub fn new_json_only(sessions_dir: PathBuf) -> std::io::Result<Self> {
198        fs::create_dir_all(&sessions_dir)?;
199        Ok(Self {
200            sessions_dir,
201            db: None,
202        })
203    }
204
205    /// Create a `SessionManager` using the default location (~/.deepseek/sessions)
206    pub fn default_location() -> std::io::Result<Self> {
207        Self::new(default_sessions_dir()?)
208    }
209
210    /// Save a session to disk using SQLite (or atomic write JSON if no DB).
211    pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
212        if let Some(ref db) = self.db {
213            sqlite_to_io(crate::persist::session_store_sqlite::save_session_sqlite(
214                &db.lock().unwrap(),
215                session,
216            ))?;
217            return self.validated_session_path(&session.metadata.id);
218        }
219
220        let path = self.validated_session_path(&session.metadata.id)?;
221        let content = serde_json::to_string_pretty(session)
222            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
223        write_atomic(&path, content.as_bytes())?;
224        self.cleanup_old_sessions()?;
225        Ok(path)
226    }
227
228    /// Load compaction artifacts for a session from SQLite (empty when JSON-only mode).
229    pub fn load_compaction_artifacts(
230        &self,
231        session_id: &str,
232    ) -> std::io::Result<Vec<zagens_core::compaction::CompactionArtifact>> {
233        if let Some(ref db) = self.db {
234            return crate::persist::load_compaction_artifacts(&db.lock().unwrap(), session_id)
235                .map_err(std::io::Error::other);
236        }
237        Ok(Vec::new())
238    }
239
240    /// Save a crash-recovery checkpoint for in-flight turns.
241    pub fn save_checkpoint(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
242        let checkpoints = self.sessions_dir.join("checkpoints");
243        fs::create_dir_all(&checkpoints)?;
244        let path = checkpoints.join("latest.json");
245        let content = serde_json::to_string_pretty(session)
246            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
247        write_atomic(&path, content.as_bytes())?;
248        Ok(path)
249    }
250
251    /// Load the most recent crash-recovery checkpoint if present.
252    pub fn load_checkpoint(&self) -> std::io::Result<Option<SavedSession>> {
253        let path = self.sessions_dir.join("checkpoints").join("latest.json");
254        if !path.exists() {
255            return Ok(None);
256        }
257        let content = fs::read_to_string(&path)?;
258        let session: SavedSession = serde_json::from_str(&content)
259            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
260        if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
261            return Err(std::io::Error::new(
262                std::io::ErrorKind::InvalidData,
263                format!(
264                    "Checkpoint schema v{} is newer than supported v{}",
265                    session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
266                ),
267            ));
268        }
269        Ok(Some(session))
270    }
271
272    /// Clear any crash-recovery checkpoint.
273    pub fn clear_checkpoint(&self) -> std::io::Result<()> {
274        let path = self.sessions_dir.join("checkpoints").join("latest.json");
275        if path.exists() {
276            fs::remove_file(path)?;
277        }
278        Ok(())
279    }
280
281    /// Save offline queue state (queued + draft messages).
282    pub fn save_offline_queue_state(
283        &self,
284        state: &OfflineQueueState,
285        session_id: Option<&str>,
286    ) -> std::io::Result<PathBuf> {
287        let checkpoints = self.sessions_dir.join("checkpoints");
288        fs::create_dir_all(&checkpoints)?;
289        let path = checkpoints.join("offline_queue.json");
290        let mut state_with_id = state.clone();
291        state_with_id.session_id = session_id.map(|s| s.to_string());
292        let content = serde_json::to_string_pretty(&state_with_id)
293            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
294        write_atomic(&path, content.as_bytes())?;
295        Ok(path)
296    }
297
298    /// Load offline queue state if present.
299    pub fn load_offline_queue_state(&self) -> std::io::Result<Option<OfflineQueueState>> {
300        let path = self
301            .sessions_dir
302            .join("checkpoints")
303            .join("offline_queue.json");
304        if !path.exists() {
305            return Ok(None);
306        }
307        let content = fs::read_to_string(&path)?;
308        let state: OfflineQueueState = serde_json::from_str(&content)
309            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
310        if state.schema_version > CURRENT_QUEUE_SCHEMA_VERSION {
311            return Err(std::io::Error::new(
312                std::io::ErrorKind::InvalidData,
313                format!(
314                    "Offline queue schema v{} is newer than supported v{}",
315                    state.schema_version, CURRENT_QUEUE_SCHEMA_VERSION
316                ),
317            ));
318        }
319        Ok(Some(state))
320    }
321
322    /// Remove persisted offline queue state.
323    pub fn clear_offline_queue_state(&self) -> std::io::Result<()> {
324        let path = self
325            .sessions_dir
326            .join("checkpoints")
327            .join("offline_queue.json");
328        if path.exists() {
329            fs::remove_file(path)?;
330        }
331        Ok(())
332    }
333
334    /// Find a persisted session id linked to a runtime thread (most recent first).
335    pub fn find_session_id_by_runtime_thread_id(
336        &self,
337        runtime_thread_id: &str,
338    ) -> std::io::Result<Option<String>> {
339        if runtime_thread_id.trim().is_empty() {
340            return Ok(None);
341        }
342        if let Some(ref db) = self.db {
343            return sqlite_to_io(
344                crate::persist::session_store_sqlite::find_session_id_by_runtime_thread_id_sqlite(
345                    &db.lock().unwrap(),
346                    runtime_thread_id,
347                ),
348            );
349        }
350        for meta in self.list_sessions()? {
351            if meta.runtime_thread_id.as_deref() == Some(runtime_thread_id) {
352                return Ok(Some(meta.id));
353            }
354        }
355        Ok(None)
356    }
357
358    /// Load a session by ID (SQLite first, then JSON fallback)
359    pub fn load_session(&self, id: &str) -> std::io::Result<SavedSession> {
360        if let Some(ref db) = self.db {
361            return sqlite_to_io(crate::persist::session_store_sqlite::load_session_sqlite(
362                &db.lock().unwrap(),
363                id,
364            ));
365        }
366
367        let path = self.validated_session_path(id)?;
368        let size_limit = max_session_file_size();
369        if size_limit > 0 {
370            let meta = path.metadata()?;
371            if meta.len() > size_limit {
372                return Err(std::io::Error::new(
373                    std::io::ErrorKind::InvalidData,
374                    format!(
375                        "Session file is {:.1} MB (limit is {:.1} MB). \
376                         Set DEEPSEEK_MAX_SESSION_FILE_MB=<mb> to raise or 0 to disable. \
377                         To shrink: delete old sessions in TUI or compact the conversation history.",
378                        meta.len() as f64 / (1024.0 * 1024.0),
379                        size_limit as f64 / (1024.0 * 1024.0),
380                    ),
381                ));
382            }
383        }
384
385        let content = fs::read_to_string(&path)?;
386        let session: SavedSession = serde_json::from_str(&content)
387            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
388        if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
389            return Err(std::io::Error::new(
390                std::io::ErrorKind::InvalidData,
391                format!(
392                    "Session schema v{} is newer than supported v{}",
393                    session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
394                ),
395            ));
396        }
397
398        Ok(session)
399    }
400
401    /// Load a session by partial ID prefix
402    pub fn load_session_by_prefix(&self, prefix: &str) -> std::io::Result<SavedSession> {
403        let sessions = self.list_sessions()?;
404
405        let matches: Vec<_> = sessions
406            .into_iter()
407            .filter(|s| s.id.starts_with(prefix))
408            .collect();
409
410        match matches.len() {
411            0 => Err(std::io::Error::new(
412                std::io::ErrorKind::NotFound,
413                format!("No session found with prefix: {prefix}"),
414            )),
415            1 => self.load_session(&matches[0].id),
416            _ => Err(std::io::Error::new(
417                std::io::ErrorKind::InvalidInput,
418                format!(
419                    "Ambiguous prefix '{}' matches {} sessions",
420                    prefix,
421                    matches.len()
422                ),
423            )),
424        }
425    }
426
427    /// List all saved sessions (SQLite indexed, then JSON fallback)
428    pub fn list_sessions(&self) -> std::io::Result<Vec<SessionMetadata>> {
429        if let Some(ref db) = self.db {
430            return sqlite_to_io(crate::persist::session_store_sqlite::list_sessions_sqlite(
431                &db.lock().unwrap(),
432            ));
433        }
434
435        let mut sessions = Vec::new();
436
437        for entry in fs::read_dir(&self.sessions_dir)? {
438            let entry = entry?;
439            let path = entry.path();
440
441            if path.extension().is_some_and(|ext| ext == "json")
442                && let Ok(session) = Self::load_session_metadata(&path)
443            {
444                sessions.push(session);
445            }
446        }
447
448        // Sort by updated_at descending (most recent first)
449        sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
450
451        Ok(sessions)
452    }
453
454    /// Load only the metadata from a session file.
455    ///
456    /// Optimization for #337: previously this called
457    /// `serde_json::from_reader` which forces serde to scan every token in
458    /// the file just to validate JSON structure — including the
459    /// (potentially many MB of) `messages` and `tool_log` arrays we're
460    /// going to discard. For a user with hundreds of long sessions, a
461    /// single `list_sessions()` call could chew through tens of MB of
462    /// JSON per startup.
463    ///
464    /// We now read at most 64 KB up front and string-extract the
465    /// top-level `metadata` object, which is invariably tiny (~500 B)
466    /// and appears before any large `messages`/`tool_log` payload. We
467    /// fall back to a full-file read only if the prefix doesn't yield a
468    /// parseable metadata block (e.g. an oddly-formatted legacy file).
469    fn load_session_metadata(path: &Path) -> std::io::Result<SessionMetadata> {
470        use std::io::Read;
471
472        const PREFIX_BYTES: usize = 64 * 1024;
473        let mut file = fs::File::open(path)?;
474        let mut buf = Vec::with_capacity(PREFIX_BYTES);
475        file.by_ref()
476            .take(PREFIX_BYTES as u64)
477            .read_to_end(&mut buf)?;
478
479        if let Some(metadata) = extract_top_level_metadata(&buf) {
480            return Ok(metadata);
481        }
482
483        // Metadata wasn't extractable from the prefix (truncated mid-block,
484        // unusual key ordering, etc.). Read the rest and try again with the
485        // full buffer before giving up.
486        let mut rest = Vec::new();
487        file.read_to_end(&mut rest)?;
488        buf.extend_from_slice(&rest);
489        extract_top_level_metadata(&buf).ok_or_else(|| {
490            std::io::Error::new(
491                std::io::ErrorKind::InvalidData,
492                "session file missing parseable `metadata` block",
493            )
494        })
495    }
496
497    /// Delete a session by ID
498    pub fn delete_session(&self, id: &str) -> std::io::Result<()> {
499        if let Some(ref db) = self.db {
500            return sqlite_to_io(crate::persist::session_store_sqlite::delete_session_sqlite(
501                &db.lock().unwrap(),
502                id,
503            ));
504        }
505        let path = self.validated_session_path(id)?;
506        fs::remove_file(path)
507    }
508
509    /// Clean up old sessions to stay within `MAX_SESSIONS` limit
510    fn cleanup_old_sessions(&self) -> std::io::Result<()> {
511        let sessions = self.list_sessions()?;
512
513        if sessions.len() > MAX_SESSIONS {
514            // Delete oldest sessions
515            for session in sessions.iter().skip(MAX_SESSIONS) {
516                let _ = self.delete_session(&session.id);
517            }
518        }
519
520        Ok(())
521    }
522
523    /// Remove session files whose `updated_at` is older than `max_age`
524    /// from the persisted-sessions directory. Returns the number of
525    /// records pruned. Building block for #406's phase-2 auto-archive
526    /// on boot; today the user-facing entry point is the
527    /// `/sessions prune <days>` slash command.
528    ///
529    /// Crash-recovery safety: skips the running checkpoint
530    /// (`checkpoints/latest.json`) and any file under `checkpoints/`
531    /// — those are owned by the checkpoint subsystem and live with
532    /// stricter durability rules. Only top-level `<session_id>.json`
533    /// files are candidates.
534    ///
535    /// `max_age` is checked against the metadata's `updated_at`
536    /// timestamp embedded in the JSON, not the filesystem mtime — the
537    /// user may have rsynced their `~/.deepseek` between machines and
538    /// fs mtimes can lie.
539    pub fn prune_sessions_older_than(
540        &self,
541        max_age: std::time::Duration,
542    ) -> std::io::Result<usize> {
543        let cutoff = Utc::now()
544            - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::days(365 * 10));
545        let sessions = self.list_sessions()?;
546        let mut pruned = 0usize;
547        for session in sessions {
548            if session.updated_at < cutoff {
549                if let Err(err) = self.delete_session(&session.id) {
550                    tracing::warn!(
551                        target: "session",
552                        session = session.id,
553                        ?err,
554                        "session prune skipped a record",
555                    );
556                    continue;
557                }
558                pruned += 1;
559            }
560        }
561        Ok(pruned)
562    }
563
564    /// Get the most recent session scoped to the current workspace.
565    pub fn get_latest_session_for_workspace(
566        &self,
567        workspace: &Path,
568    ) -> std::io::Result<Option<SessionMetadata>> {
569        let sessions = self.list_sessions()?;
570        Ok(sessions
571            .into_iter()
572            .find(|session| workspace_scope_matches(&session.workspace, workspace)))
573    }
574
575    /// Search sessions by title
576    pub fn search_sessions(&self, query: &str) -> std::io::Result<Vec<SessionMetadata>> {
577        let query_lower = query.to_lowercase();
578        let sessions = self.list_sessions()?;
579
580        Ok(sessions
581            .into_iter()
582            .filter(|s| s.title.to_lowercase().contains(&query_lower))
583            .collect())
584    }
585}
586
587fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> bool {
588    if paths_equivalent(saved_workspace, current_workspace) {
589        return true;
590    }
591
592    match (
593        find_git_root(saved_workspace),
594        find_git_root(current_workspace),
595    ) {
596        (Some(saved_root), Some(current_root)) => paths_equivalent(&saved_root, &current_root),
597        _ => false,
598    }
599}
600
601fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
602    let lhs_canonical = fs::canonicalize(lhs).ok();
603    let rhs_canonical = fs::canonicalize(rhs).ok();
604    match (lhs_canonical, rhs_canonical) {
605        (Some(lhs), Some(rhs)) => lhs == rhs,
606        _ => lhs == rhs,
607    }
608}
609
610fn find_git_root(path: &Path) -> Option<PathBuf> {
611    let mut current = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
612    loop {
613        if current.join(".git").exists() {
614            return Some(current);
615        }
616        match current.parent() {
617            Some(parent) if parent != current => current = parent.to_path_buf(),
618            _ => return None,
619        }
620    }
621}
622
623/// Resolve the default session directory path (`~/.zagens/sessions`).
624pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
625    zagens_config::user_data_path("sessions").map_err(|e| {
626        std::io::Error::new(
627            std::io::ErrorKind::NotFound,
628            format!("Home directory not found: {e}"),
629        )
630    })
631}
632
633/// Prune snapshots older than `max_age` for `workspace`.
634///
635/// Always non-fatal. Returns silently — callers don't need the count
636/// (the underlying repo logs at WARN if anything blew up).
637pub fn prune_workspace_snapshots(workspace: &Path, max_age: std::time::Duration) {
638    match crate::snapshot::prune_older_than(workspace, max_age) {
639        Ok(0) => {}
640        Ok(n) => {
641            tracing::debug!(target: "snapshot", "boot prune removed {n} snapshot(s)");
642        }
643        Err(e) => {
644            tracing::warn!(target: "snapshot", "boot prune failed: {e}");
645        }
646    }
647}
648
649/// Create a new `SavedSession` from conversation state
650pub fn create_saved_session(
651    messages: &[Message],
652    model: &str,
653    workspace: &Path,
654    total_tokens: u64,
655    system_prompt: Option<&SystemPrompt>,
656) -> SavedSession {
657    create_saved_session_with_mode(
658        messages,
659        model,
660        workspace,
661        total_tokens,
662        system_prompt,
663        None,
664    )
665}
666
667/// Create a new `SavedSession` from conversation state with optional mode label
668pub fn create_saved_session_with_mode(
669    messages: &[Message],
670    model: &str,
671    workspace: &Path,
672    total_tokens: u64,
673    system_prompt: Option<&SystemPrompt>,
674    mode: Option<&str>,
675) -> SavedSession {
676    let id = Uuid::new_v4().to_string();
677    let now = Utc::now();
678
679    // Generate title from first user message
680    let title = messages
681        .iter()
682        .find(|m| m.role == "user")
683        .and_then(|m| {
684            m.content.iter().find_map(|block| match block {
685                ContentBlock::Text { text, .. } => Some(truncate_title(text, 50)),
686                _ => None,
687            })
688        })
689        .unwrap_or_else(|| "New Session".to_string());
690
691    let (mut capped_messages, truncation_note) = cap_messages(messages);
692    strip_thinking_blocks(&mut capped_messages);
693
694    SavedSession {
695        schema_version: CURRENT_SESSION_SCHEMA_VERSION,
696        metadata: SessionMetadata {
697            id,
698            title,
699            created_at: now,
700            updated_at: now,
701            message_count: messages.len(),
702            total_tokens,
703            model: model.to_string(),
704            workspace: workspace.to_path_buf(),
705            mode: mode.map(str::to_string),
706            runtime_thread_id: None,
707        },
708        messages: capped_messages,
709        system_prompt: merge_truncation_note(
710            system_prompt_to_string(system_prompt),
711            truncation_note,
712        ),
713        context_references: Vec::new(),
714    }
715}
716
717/// Update an existing session with new messages
718pub fn update_session(
719    mut session: SavedSession,
720    messages: &[Message],
721    total_tokens: u64,
722    system_prompt: Option<&SystemPrompt>,
723) -> SavedSession {
724    session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
725    let (mut capped_messages, truncation_note) = cap_messages(messages);
726    strip_thinking_blocks(&mut capped_messages);
727    session.messages = capped_messages;
728    session.metadata.updated_at = Utc::now();
729    session.metadata.message_count = messages.len();
730    session.metadata.total_tokens = total_tokens;
731    session.system_prompt = merge_truncation_note(
732        system_prompt_to_string(system_prompt).or(session.system_prompt),
733        truncation_note,
734    );
735    session
736}
737
738/// Cap messages to [`MAX_PERSISTED_MESSAGES`], keeping the most recent.
739/// Returns the capped slice and an optional truncation note.
740fn cap_messages(messages: &[Message]) -> (Vec<Message>, Option<String>) {
741    let total = messages.len();
742    if total <= MAX_PERSISTED_MESSAGES {
743        return (messages.to_vec(), None);
744    }
745    let dropped = total - MAX_PERSISTED_MESSAGES;
746    let note = format!(
747        "Note: {dropped} older messages were dropped from the session file \
748         to keep persistence bounded. The full conversation history may \
749         still be recoverable from cycle archives."
750    );
751    (
752        messages[total - MAX_PERSISTED_MESSAGES..].to_vec(),
753        Some(note),
754    )
755}
756
757/// Strip [`ContentBlock::Thinking`] blocks from saved messages.
758///
759/// Reasoning content (thinking tokens) is only needed during an active API
760/// turn for tool-call replay. Persisting it across sessions wastes disk and
761/// inflates session files — V4 thinking can reach tens of thousands of tokens
762/// per turn. The text answer and tool results are preserved.
763fn strip_thinking_blocks(messages: &mut [Message]) {
764    for msg in messages {
765        msg.content
766            .retain(|block| !matches!(block, ContentBlock::Thinking { .. }));
767    }
768}
769
770/// Merge an optional truncation note into the system prompt string.
771fn merge_truncation_note(system_prompt: Option<String>, note: Option<String>) -> Option<String> {
772    match (system_prompt, note) {
773        (None, None) => None,
774        (Some(sp), None) => Some(sp),
775        (None, Some(note)) => Some(format!("[Session note]\n{note}")),
776        (Some(sp), Some(note)) => Some(format!("[Session note]\n{note}\n\n---\n\n{sp}")),
777    }
778}
779
780/// String-scan a JSON byte buffer for the top-level `"metadata":{...}`
781/// block and return it parsed. Returns `None` if no balanced metadata
782/// object is present in the buffer.
783///
784/// Supports the optimisation in `SessionManager::load_session_metadata`
785/// (#337). The scanner is brace-balanced and string-aware so a `{` or
786/// `}` appearing inside a string literal doesn't perturb the depth
787/// count.
788fn extract_top_level_metadata(buf: &[u8]) -> Option<SessionMetadata> {
789    let s = std::str::from_utf8(buf).ok()?;
790    let bytes = s.as_bytes();
791
792    // Find the FIRST `"metadata"` key that appears outside of any string
793    // literal. Walking with brace/string awareness costs almost nothing
794    // and avoids matching `metadata` inside an earlier message body.
795    let key_pat = b"\"metadata\"";
796    let mut idx = 0usize;
797    let mut in_string = false;
798    let mut escape = false;
799    let key_offset = loop {
800        if idx >= bytes.len() {
801            return None;
802        }
803        let c = bytes[idx];
804        if escape {
805            escape = false;
806            idx += 1;
807            continue;
808        }
809        if c == b'\\' {
810            escape = true;
811            idx += 1;
812            continue;
813        }
814        if c == b'"' {
815            // If we're already in a string, this closes it; otherwise it
816            // opens one. But before flipping we check for the key match
817            // when we're entering a string at exactly this position.
818            if !in_string && bytes[idx..].starts_with(key_pat) {
819                break idx;
820            }
821            in_string = !in_string;
822            idx += 1;
823            continue;
824        }
825        idx += 1;
826    };
827
828    // Position past the key.
829    let after_key = key_offset + key_pat.len();
830    // Find the colon that separates key from value (skip whitespace).
831    let mut after_colon = after_key;
832    while after_colon < bytes.len() && (bytes[after_colon] as char).is_whitespace() {
833        after_colon += 1;
834    }
835    if after_colon >= bytes.len() || bytes[after_colon] != b':' {
836        return None;
837    }
838    after_colon += 1;
839    while after_colon < bytes.len() && (bytes[after_colon] as char).is_whitespace() {
840        after_colon += 1;
841    }
842    if after_colon >= bytes.len() || bytes[after_colon] != b'{' {
843        return None;
844    }
845
846    // Walk the object, balancing braces.
847    let mut depth = 0i32;
848    let mut in_string = false;
849    let mut escape = false;
850    let mut end = None;
851    for (i, &c) in bytes[after_colon..].iter().enumerate() {
852        let abs = after_colon + i;
853        if escape {
854            escape = false;
855            continue;
856        }
857        if c == b'\\' {
858            escape = true;
859            continue;
860        }
861        if c == b'"' {
862            in_string = !in_string;
863            continue;
864        }
865        if in_string {
866            continue;
867        }
868        match c {
869            b'{' => depth += 1,
870            b'}' => {
871                depth -= 1;
872                if depth == 0 {
873                    end = Some(abs + 1);
874                    break;
875                }
876            }
877            _ => {}
878        }
879    }
880    let end = end?;
881    serde_json::from_str::<SessionMetadata>(&s[after_colon..end]).ok()
882}
883
884fn system_prompt_to_string(system_prompt: Option<&SystemPrompt>) -> Option<String> {
885    match system_prompt {
886        Some(SystemPrompt::Text(text)) => Some(text.clone()),
887        Some(SystemPrompt::Blocks(blocks)) => Some(
888            blocks
889                .iter()
890                .map(|b| b.text.clone())
891                .collect::<Vec<_>>()
892                .join("\n\n---\n\n"),
893        ),
894        None => None,
895    }
896}
897
898/// Truncate a session ID to 8 characters for compact display.
899/// Returns a `&str` borrowing from the input — no allocation.
900pub fn truncate_id(id: &str) -> &str {
901    id.get(..8).unwrap_or(id)
902}
903
904/// Truncate a string to create a title (character-safe for UTF-8)
905fn truncate_title(s: &str, max_len: usize) -> String {
906    let s = s.trim();
907    let first_line = s.lines().next().unwrap_or(s);
908
909    let char_count = first_line.chars().count();
910    if char_count <= max_len {
911        first_line.to_string()
912    } else {
913        let truncated: String = first_line.chars().take(max_len - 3).collect();
914        format!("{truncated}...")
915    }
916}
917
918/// Format a session for display in a picker
919pub fn format_session_line(meta: &SessionMetadata) -> String {
920    let age = format_age(&meta.updated_at);
921    let truncated_title = truncate_title(&meta.title, 40);
922
923    format!(
924        "{} | {} | {} msgs | {}",
925        truncate_id(&meta.id),
926        truncated_title,
927        meta.message_count,
928        age
929    )
930}
931
932/// Format a datetime as relative age
933fn format_age(dt: &DateTime<Utc>) -> String {
934    let now = Utc::now();
935    let duration = now.signed_duration_since(*dt);
936
937    if duration.num_minutes() < 1 {
938        "just now".to_string()
939    } else if duration.num_hours() < 1 {
940        format!("{}m ago", duration.num_minutes())
941    } else if duration.num_days() < 1 {
942        format!("{}h ago", duration.num_hours())
943    } else if duration.num_weeks() < 1 {
944        format!("{}d ago", duration.num_days())
945    } else {
946        format!("{}w ago", duration.num_weeks())
947    }
948}
949
950/// Convert an `anyhow::Error` to `std::io::Error`, preserving NotFound / InvalidInput
951/// semantics for the session API error mapping.
952fn sqlite_to_io<T>(r: anyhow::Result<T>) -> std::io::Result<T> {
953    r.map_err(|e| {
954        let msg = format!("{e:#}");
955        // Check for NotFound / InvalidInput patterns
956        if msg.contains("not found") || msg.contains("NOT FOUND") {
957            std::io::Error::new(std::io::ErrorKind::NotFound, msg)
958        } else if msg.contains("InvalidInput")
959            || msg.contains("Invalid session id")
960            || msg.contains("cannot be empty")
961        {
962            std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
963        } else {
964            std::io::Error::other(msg)
965        }
966    })
967}
968
969// === Unit Tests ===
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::models::ContentBlock;
975    use std::fs;
976    use tempfile::tempdir;
977
978    fn make_test_message(role: &str, text: &str) -> Message {
979        Message {
980            role: role.to_string(),
981            content: vec![ContentBlock::Text {
982                text: text.to_string(),
983                cache_control: None,
984            }],
985        }
986    }
987
988    fn write_session_record(
989        manager: &SessionManager,
990        id: &str,
991        workspace: &Path,
992        updated_at: DateTime<Utc>,
993    ) {
994        let session = SavedSession {
995            schema_version: CURRENT_SESSION_SCHEMA_VERSION,
996            messages: vec![make_test_message("user", "hi")],
997            metadata: SessionMetadata {
998                id: id.to_string(),
999                title: format!("session-{id}"),
1000                created_at: updated_at,
1001                updated_at,
1002                message_count: 1,
1003                total_tokens: 0,
1004                model: "deepseek-v4-flash".to_string(),
1005                workspace: workspace.to_path_buf(),
1006                mode: None,
1007                runtime_thread_id: None,
1008            },
1009            system_prompt: None,
1010            context_references: Vec::new(),
1011        };
1012        manager.save_session(&session).expect("save");
1013    }
1014
1015    #[test]
1016    fn test_session_manager_new() {
1017        let tmp = tempdir().expect("tempdir");
1018        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1019        assert!(tmp.path().join("sessions").exists());
1020        let _ = manager;
1021    }
1022
1023    #[test]
1024    fn test_save_and_load_session() {
1025        let tmp = tempdir().expect("tempdir");
1026        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1027
1028        let messages = vec![
1029            make_test_message("user", "Hello!"),
1030            make_test_message("assistant", "Hi there!"),
1031        ];
1032
1033        let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1034        let session_id = session.metadata.id.clone();
1035
1036        manager.save_session(&session).expect("save");
1037
1038        let loaded = manager.load_session(&session_id).expect("load");
1039        assert_eq!(loaded.metadata.id, session_id);
1040        assert_eq!(loaded.messages.len(), 2);
1041    }
1042
1043    #[test]
1044    fn test_list_sessions() {
1045        let tmp = tempdir().expect("tempdir");
1046        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1047
1048        // Create a few sessions
1049        for i in 0..3 {
1050            let messages = vec![make_test_message("user", &format!("Session {i}"))];
1051            let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1052            manager.save_session(&session).expect("save");
1053        }
1054
1055        let sessions = manager.list_sessions().expect("list");
1056        assert_eq!(sessions.len(), 3);
1057    }
1058
1059    #[test]
1060    fn latest_session_for_workspace_ignores_newer_other_directory() {
1061        let tmp = tempdir().expect("tempdir");
1062        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1063        let workspace_a = tmp.path().join("aa").join("aaa");
1064        let workspace_b = tmp.path().join("bb").join("bbb");
1065        fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
1066        fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
1067        fs::create_dir_all(tmp.path().join("aa").join(".git")).expect("mkdir .git for a");
1068        fs::create_dir_all(tmp.path().join("bb").join(".git")).expect("mkdir .git for b");
1069
1070        write_session_record(
1071            &manager,
1072            "current-workspace",
1073            &workspace_a,
1074            Utc::now() - chrono::Duration::minutes(10),
1075        );
1076        write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
1077
1078        let global = manager
1079            .list_sessions()
1080            .expect("list")
1081            .into_iter()
1082            .next()
1083            .expect("global latest");
1084        assert_eq!(global.id, "other-workspace");
1085
1086        let scoped = manager
1087            .get_latest_session_for_workspace(&workspace_a)
1088            .expect("latest for workspace")
1089            .expect("scoped latest");
1090        assert_eq!(scoped.id, "current-workspace");
1091    }
1092
1093    #[test]
1094    fn latest_session_for_workspace_matches_same_git_repository() {
1095        let tmp = tempdir().expect("tempdir");
1096        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1097        let repo = tmp.path().join("repo");
1098        let repo_app = repo.join("apps").join("client");
1099        let repo_crate = repo.join("crates").join("server");
1100        let other_repo = tmp.path().join("other").join("project");
1101        fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
1102        fs::create_dir_all(&repo_app).expect("mkdir repo app");
1103        fs::create_dir_all(&repo_crate).expect("mkdir repo crate");
1104        fs::create_dir_all(&other_repo).expect("mkdir other repo");
1105
1106        write_session_record(
1107            &manager,
1108            "same-repo",
1109            &repo_app,
1110            Utc::now() - chrono::Duration::minutes(5),
1111        );
1112        write_session_record(&manager, "other-repo", &other_repo, Utc::now());
1113
1114        let scoped = manager
1115            .get_latest_session_for_workspace(&repo_crate)
1116            .expect("latest for workspace")
1117            .expect("same repo latest");
1118        assert_eq!(scoped.id, "same-repo");
1119    }
1120
1121    #[test]
1122    fn test_load_by_prefix() {
1123        let tmp = tempdir().expect("tempdir");
1124        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1125
1126        let messages = vec![make_test_message("user", "Test session")];
1127        let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1128        let prefix = truncate_id(&session.metadata.id).to_string();
1129        manager.save_session(&session).expect("save");
1130
1131        let loaded = manager.load_session_by_prefix(&prefix).expect("load");
1132        assert_eq!(loaded.messages.len(), 1);
1133    }
1134
1135    #[test]
1136    fn test_delete_session() {
1137        let tmp = tempdir().expect("tempdir");
1138        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1139
1140        let messages = vec![make_test_message("user", "To be deleted")];
1141        let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
1142        let session_id = session.metadata.id.clone();
1143
1144        manager.save_session(&session).expect("save");
1145        assert!(manager.load_session(&session_id).is_ok());
1146
1147        manager.delete_session(&session_id).expect("delete");
1148        assert!(manager.load_session(&session_id).is_err());
1149    }
1150
1151    #[test]
1152    fn test_session_id_rejects_invalid_characters() {
1153        let tmp = tempdir().expect("tempdir");
1154        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1155
1156        let err = manager
1157            .load_session("../outside")
1158            .expect_err("invalid id should fail");
1159        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1160
1161        let err = manager
1162            .delete_session("sess bad")
1163            .expect_err("invalid id should fail");
1164        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1165    }
1166
1167    #[test]
1168    fn test_truncate_title() {
1169        assert_eq!(truncate_title("Short", 50), "Short");
1170        assert_eq!(
1171            truncate_title("This is a very long title that should be truncated", 20),
1172            "This is a very lo..."
1173        );
1174        assert_eq!(truncate_title("Line 1\nLine 2", 50), "Line 1");
1175    }
1176
1177    #[test]
1178    fn test_format_age() {
1179        let now = Utc::now();
1180        assert_eq!(format_age(&now), "just now");
1181
1182        let hour_ago = now - chrono::Duration::hours(2);
1183        assert_eq!(format_age(&hour_ago), "2h ago");
1184
1185        let day_ago = now - chrono::Duration::days(3);
1186        assert_eq!(format_age(&day_ago), "3d ago");
1187    }
1188
1189    #[test]
1190    fn test_update_session() {
1191        let tmp = tempdir().expect("tempdir");
1192
1193        let messages = vec![make_test_message("user", "Hello")];
1194        let session = create_saved_session(&messages, "test-model", tmp.path(), 50, None);
1195
1196        let new_messages = vec![
1197            make_test_message("user", "Hello"),
1198            make_test_message("assistant", "Hi!"),
1199        ];
1200
1201        let updated = update_session(session, &new_messages, 100, None);
1202        assert_eq!(updated.messages.len(), 2);
1203        assert_eq!(updated.metadata.total_tokens, 100);
1204    }
1205
1206    #[test]
1207    fn test_checkpoint_round_trip_and_clear() {
1208        let tmp = tempdir().expect("tempdir");
1209        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1210        let messages = vec![make_test_message("user", "checkpoint me")];
1211        let session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
1212
1213        manager.save_checkpoint(&session).expect("save checkpoint");
1214        let loaded = manager
1215            .load_checkpoint()
1216            .expect("load checkpoint")
1217            .expect("checkpoint exists");
1218        assert_eq!(loaded.metadata.id, session.metadata.id);
1219
1220        manager.clear_checkpoint().expect("clear checkpoint");
1221        assert!(
1222            manager
1223                .load_checkpoint()
1224                .expect("load checkpoint")
1225                .is_none()
1226        );
1227    }
1228
1229    #[test]
1230    fn test_offline_queue_round_trip_and_clear() {
1231        let tmp = tempdir().expect("tempdir");
1232        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1233
1234        let state = OfflineQueueState {
1235            messages: vec![QueuedSessionMessage {
1236                display: "queued message".to_string(),
1237                skill_instruction: Some("Use skill".to_string()),
1238            }],
1239            draft: Some(QueuedSessionMessage {
1240                display: "draft message".to_string(),
1241                skill_instruction: None,
1242            }),
1243            ..OfflineQueueState::default()
1244        };
1245
1246        manager
1247            .save_offline_queue_state(&state, Some("test-session"))
1248            .expect("save queue state");
1249        let loaded = manager
1250            .load_offline_queue_state()
1251            .expect("load queue state")
1252            .expect("queue state exists");
1253        assert_eq!(loaded.messages.len(), 1);
1254        assert_eq!(loaded.messages[0].display, "queued message");
1255        assert!(loaded.draft.is_some());
1256
1257        manager
1258            .clear_offline_queue_state()
1259            .expect("clear queue state");
1260        assert!(
1261            manager
1262                .load_offline_queue_state()
1263                .expect("load queue state")
1264                .is_none()
1265        );
1266    }
1267
1268    #[test]
1269    fn test_offline_queue_stamps_session_id_on_save() {
1270        // #487: save_offline_queue_state must stamp the supplied
1271        // session id so the load path's mismatch check has something
1272        // to compare against. A queue persisted without a session id
1273        // is the legacy unscoped form which the load path treats as
1274        // stale-risky and refuses to restore.
1275        let tmp = tempdir().expect("tempdir");
1276        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1277
1278        let state = OfflineQueueState {
1279            messages: vec![QueuedSessionMessage {
1280                display: "first parked".to_string(),
1281                skill_instruction: None,
1282            }],
1283            ..OfflineQueueState::default()
1284        };
1285
1286        manager
1287            .save_offline_queue_state(&state, Some("session-A"))
1288            .expect("save with session id");
1289        let loaded = manager
1290            .load_offline_queue_state()
1291            .expect("ok")
1292            .expect("present");
1293        assert_eq!(loaded.session_id.as_deref(), Some("session-A"));
1294
1295        // Re-saving with a different session id replaces the stamp.
1296        manager
1297            .save_offline_queue_state(&state, Some("session-B"))
1298            .expect("re-save");
1299        let reloaded = manager
1300            .load_offline_queue_state()
1301            .expect("ok")
1302            .expect("present");
1303        assert_eq!(reloaded.session_id.as_deref(), Some("session-B"));
1304
1305        // Saving without a session id explicitly (None) clears the
1306        // stamp — UI's load path treats that as legacy-unscoped and
1307        // fails closed.
1308        manager
1309            .save_offline_queue_state(&state, None)
1310            .expect("save without session id");
1311        let unscoped = manager
1312            .load_offline_queue_state()
1313            .expect("ok")
1314            .expect("present");
1315        assert!(
1316            unscoped.session_id.is_none(),
1317            "save with None must persist a missing session_id, got {:?}",
1318            unscoped.session_id
1319        );
1320    }
1321
1322    #[test]
1323    fn test_session_context_references_round_trip() {
1324        let tmp = tempdir().expect("tempdir");
1325        let manager = SessionManager::new_json_only(tmp.path().join("sessions")).expect("new");
1326        let mut session = create_saved_session(
1327            &[make_test_message("user", "read @src/main.rs")],
1328            "deepseek-v4-pro",
1329            tmp.path(),
1330            0,
1331            None,
1332        );
1333        session.context_references.push(SessionContextReference {
1334            message_index: 0,
1335            reference: ContextReference {
1336                kind: crate::persist::context_reference::ContextReferenceKind::File,
1337                source: crate::persist::context_reference::ContextReferenceSource::AtMention,
1338                badge: "file".to_string(),
1339                label: "src/main.rs".to_string(),
1340                target: tmp.path().join("src/main.rs").display().to_string(),
1341                included: true,
1342                expanded: true,
1343                detail: Some("included".to_string()),
1344            },
1345        });
1346
1347        let path = manager.save_session(&session).expect("save session");
1348        let loaded = manager
1349            .load_session(&session.metadata.id)
1350            .expect("load session");
1351        assert!(path.exists());
1352        assert_eq!(loaded.context_references, session.context_references);
1353    }
1354
1355    #[test]
1356    fn test_checkpoint_rejects_newer_schema() {
1357        let tmp = tempdir().expect("tempdir");
1358        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1359        let checkpoints = tmp.path().join("sessions").join("checkpoints");
1360        fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
1361        let path = checkpoints.join("latest.json");
1362        fs::write(
1363            &path,
1364            r#"{
1365                "schema_version": 999,
1366                "metadata": {
1367                    "id": "sid",
1368                    "title": "bad",
1369                    "created_at": "2026-01-01T00:00:00Z",
1370                    "updated_at": "2026-01-01T00:00:00Z",
1371                    "message_count": 0,
1372                    "total_tokens": 0,
1373                    "model": "m",
1374                    "workspace": "/tmp",
1375                    "mode": null
1376                },
1377                "messages": [],
1378                "system_prompt": null
1379            }"#,
1380        )
1381        .expect("write checkpoint");
1382
1383        let err = manager.load_checkpoint().expect_err("should reject schema");
1384        assert!(err.to_string().contains("newer than supported"));
1385    }
1386
1387    #[test]
1388    fn test_load_session_rejects_newer_schema() {
1389        let tmp = tempdir().expect("tempdir");
1390        let sessions_dir = tmp.path().join("sessions");
1391        let manager = SessionManager::new_json_only(sessions_dir.clone()).expect("new");
1392
1393        let id = "future-session";
1394        let path = sessions_dir.join(format!("{id}.json"));
1395        fs::write(
1396            &path,
1397            r#"{
1398                "schema_version": 999,
1399                "metadata": {
1400                    "id": "future-session",
1401                    "title": "future",
1402                    "created_at": "2026-01-01T00:00:00Z",
1403                    "updated_at": "2026-01-01T00:00:00Z",
1404                    "message_count": 0,
1405                    "total_tokens": 0,
1406                    "model": "m",
1407                    "workspace": "/tmp",
1408                    "mode": null
1409                },
1410                "messages": [],
1411                "system_prompt": null
1412            }"#,
1413        )
1414        .expect("write session");
1415
1416        let err = manager.load_session(id).expect_err("should reject schema");
1417        assert!(
1418            err.to_string().contains("newer than supported"),
1419            "unexpected error: {err}"
1420        );
1421    }
1422
1423    /// Regression for #337: metadata extraction skips the (potentially
1424    /// huge) `messages` array — it must succeed even when the messages
1425    /// array is megabytes long, and it must NOT confuse a `"metadata"`
1426    /// substring inside a message body for the real top-level key.
1427    #[test]
1428    fn extract_top_level_metadata_skips_huge_messages_array() {
1429        // Build a session JSON with a large `messages` payload that
1430        // contains the literal string `"metadata"` in a user message —
1431        // a naive `find("\"metadata\"")` would mis-target this.
1432        let big_text = format!(
1433            r#"this message references "metadata" inside it, repeated:{}"#,
1434            "x".repeat(20_000)
1435        );
1436        let json = format!(
1437            r#"{{
1438                "schema_version": 1,
1439                "metadata": {{
1440                    "id": "abc-123",
1441                    "title": "Real Session",
1442                    "created_at": "2026-01-01T00:00:00Z",
1443                    "updated_at": "2026-01-02T00:00:00Z",
1444                    "message_count": 12,
1445                    "total_tokens": 4096,
1446                    "model": "deepseek-v4-flash",
1447                    "workspace": "/tmp"
1448                }},
1449                "messages": [
1450                    {{ "role": "user", "content": [ {{ "Text": {{ "text": {body:?} }} }} ] }}
1451                ]
1452            }}"#,
1453            body = big_text
1454        );
1455
1456        let extracted =
1457            extract_top_level_metadata(json.as_bytes()).expect("metadata extractable from prefix");
1458        assert_eq!(extracted.id, "abc-123");
1459        assert_eq!(extracted.title, "Real Session");
1460        assert_eq!(extracted.message_count, 12);
1461        assert_eq!(extracted.total_tokens, 4096);
1462    }
1463
1464    #[test]
1465    fn extract_top_level_metadata_handles_braces_inside_strings() {
1466        // A title containing `{` and `}` inside the metadata block must
1467        // not throw off the brace counter.
1468        let json = r#"{
1469            "metadata": {
1470                "id": "x",
1471                "title": "weird { title } with braces",
1472                "created_at": "2026-01-01T00:00:00Z",
1473                "updated_at": "2026-01-01T00:00:00Z",
1474                "message_count": 0,
1475                "total_tokens": 0,
1476                "model": "m",
1477                "workspace": "/tmp"
1478            },
1479            "messages": []
1480        }"#;
1481        let extracted = extract_top_level_metadata(json.as_bytes())
1482            .expect("brace-in-string survives the scanner");
1483        assert_eq!(extracted.title, "weird { title } with braces");
1484    }
1485
1486    // ---- #406 prune_sessions_older_than ----
1487    //
1488    // The helper is a building block for the auto-archive design: it
1489    // removes session files older than a threshold while leaving fresh
1490    // ones (and the checkpoint directory) alone. Tests cover the empty
1491    // case, the all-fresh case, the all-stale case, and the mixed case.
1492
1493    fn write_session_with_updated_at(
1494        manager: &SessionManager,
1495        id: &str,
1496        updated_at: DateTime<Utc>,
1497    ) {
1498        // Build a minimal SavedSession by hand so the test isn't tied
1499        // to whatever the helper functions emit; we just need a
1500        // metadata block whose `updated_at` matches the requested
1501        // value.
1502        write_session_record(manager, id, Path::new("/tmp"), updated_at);
1503    }
1504
1505    #[test]
1506    fn prune_sessions_older_than_returns_zero_for_empty_dir() {
1507        let tmp = tempdir().expect("tempdir");
1508        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1509        let pruned = manager
1510            .prune_sessions_older_than(std::time::Duration::from_secs(3600))
1511            .expect("prune");
1512        assert_eq!(pruned, 0);
1513    }
1514
1515    #[test]
1516    fn prune_sessions_older_than_keeps_fresh_records() {
1517        let tmp = tempdir().expect("tempdir");
1518        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1519        // All updated within the last hour.
1520        write_session_with_updated_at(
1521            &manager,
1522            "fresh-1",
1523            Utc::now() - chrono::Duration::minutes(30),
1524        );
1525        write_session_with_updated_at(
1526            &manager,
1527            "fresh-2",
1528            Utc::now() - chrono::Duration::minutes(5),
1529        );
1530        let pruned = manager
1531            .prune_sessions_older_than(std::time::Duration::from_secs(3600))
1532            .expect("prune");
1533        assert_eq!(pruned, 0);
1534        // Both files still on disk.
1535        assert_eq!(manager.list_sessions().expect("list").len(), 2);
1536    }
1537
1538    #[test]
1539    fn prune_sessions_older_than_removes_stale_records() {
1540        let tmp = tempdir().expect("tempdir");
1541        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1542        // Two stale records ≥7 days old.
1543        write_session_with_updated_at(&manager, "stale-1", Utc::now() - chrono::Duration::days(8));
1544        write_session_with_updated_at(&manager, "stale-2", Utc::now() - chrono::Duration::days(30));
1545        let pruned = manager
1546            .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
1547            .expect("prune");
1548        assert_eq!(pruned, 2);
1549        assert_eq!(manager.list_sessions().expect("list").len(), 0);
1550    }
1551
1552    #[test]
1553    fn prune_sessions_older_than_only_removes_stale_records_in_mixed_dir() {
1554        let tmp = tempdir().expect("tempdir");
1555        let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1556        write_session_with_updated_at(&manager, "fresh", Utc::now() - chrono::Duration::hours(1));
1557        write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
1558        let pruned = manager
1559            .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
1560            .expect("prune");
1561        assert_eq!(pruned, 1);
1562        let remaining = manager.list_sessions().expect("list");
1563        assert_eq!(remaining.len(), 1);
1564        assert_eq!(remaining[0].id, "fresh");
1565    }
1566
1567    #[test]
1568    fn prune_sessions_older_than_skips_checkpoint_directory() {
1569        // The checkpoint subsystem owns `<sessions>/checkpoints/` —
1570        // prune must not walk into it. The list_sessions iterator
1571        // already filters to top-level `*.json` files (skipping
1572        // sub-directories), so this test pins that behaviour.
1573        let tmp = tempdir().expect("tempdir");
1574        let sessions_dir = tmp.path().join("sessions");
1575        let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1576        let checkpoint_dir = sessions_dir.join("checkpoints");
1577        fs::create_dir_all(&checkpoint_dir).expect("mkdir checkpoints");
1578        // Drop a stale-looking JSON inside the checkpoint dir; prune
1579        // should leave it alone.
1580        let checkpoint_file = checkpoint_dir.join("latest.json");
1581        fs::write(&checkpoint_file, "{}").expect("write checkpoint");
1582
1583        write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
1584        let pruned = manager
1585            .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
1586            .expect("prune");
1587        assert_eq!(pruned, 1, "the top-level stale session should be removed");
1588        assert!(
1589            checkpoint_file.exists(),
1590            "checkpoint file should be untouched"
1591        );
1592    }
1593
1594    #[test]
1595    fn test_load_offline_queue_rejects_newer_schema() {
1596        let tmp = tempdir().expect("tempdir");
1597        let sessions_dir = tmp.path().join("sessions");
1598        let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1599        let checkpoints = sessions_dir.join("checkpoints");
1600        fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
1601        let path = checkpoints.join("offline_queue.json");
1602        fs::write(
1603            &path,
1604            r#"{
1605                "schema_version": 999,
1606                "messages": [],
1607                "draft": null
1608            }"#,
1609        )
1610        .expect("write queue");
1611
1612        let err = manager
1613            .load_offline_queue_state()
1614            .expect_err("should reject schema");
1615        assert!(
1616            err.to_string().contains("newer than supported"),
1617            "unexpected error: {err}"
1618        );
1619    }
1620}