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