1use 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
19const MAX_SESSIONS: usize = 50;
21const MAX_PERSISTED_MESSAGES: usize = 500;
26const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
27const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
28const DEFAULT_MAX_SESSION_FILE_SIZE: u64 = 5 * 1024 * 1024;
32
33fn max_session_file_size() -> u64 {
34 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct QueuedSessionMessage {
67 pub display: String,
68 #[serde(default)]
69 pub skill_instruction: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct OfflineQueueState {
75 #[serde(default = "default_queue_schema_version")]
76 pub schema_version: u32,
77 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SessionContextReference {
101 pub message_index: usize,
102 pub reference: ContextReference,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
107pub struct SessionMetadata {
108 pub id: String,
110 pub title: String,
112 pub created_at: DateTime<Utc>,
114 pub updated_at: DateTime<Utc>,
116 pub message_count: usize,
118 pub total_tokens: u64,
120 pub model: String,
122 #[schemars(schema_with = "crate::json_schema_util::path_as_string")]
124 pub workspace: PathBuf,
125 #[serde(default)]
127 pub mode: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub runtime_thread_id: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SavedSession {
136 #[serde(default = "default_session_schema_version")]
138 pub schema_version: u32,
139 pub metadata: SessionMetadata,
141 pub messages: Vec<Message>,
143 pub system_prompt: Option<String>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub context_references: Vec<SessionContextReference>,
149}
150
151pub struct SessionManager {
153 sessions_dir: PathBuf,
155 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 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 #[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 pub fn default_location() -> std::io::Result<Self> {
207 Self::new(default_sessions_dir()?)
208 }
209
210 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 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 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 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 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 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 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 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 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 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 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 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 sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
450
451 Ok(sessions)
452 }
453
454 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 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 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 fn cleanup_old_sessions(&self) -> std::io::Result<()> {
511 let sessions = self.list_sessions()?;
512
513 if sessions.len() > MAX_SESSIONS {
514 for session in sessions.iter().skip(MAX_SESSIONS) {
516 let _ = self.delete_session(&session.id);
517 }
518 }
519
520 Ok(())
521 }
522
523 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 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 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, ¤t_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
623pub 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
633pub 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
649pub 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
667pub 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 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
717pub 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
738fn 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
757fn strip_thinking_blocks(messages: &mut [Message]) {
764 for msg in messages {
765 msg.content
766 .retain(|block| !matches!(block, ContentBlock::Thinking { .. }));
767 }
768}
769
770fn 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
780fn 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 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 !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 let after_key = key_offset + key_pat.len();
830 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 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
898pub fn truncate_id(id: &str) -> &str {
901 id.get(..8).unwrap_or(id)
902}
903
904fn 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
918pub 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
932fn 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
950fn sqlite_to_io<T>(r: anyhow::Result<T>) -> std::io::Result<T> {
953 r.map_err(|e| {
954 let msg = format!("{e:#}");
955 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#[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 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 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 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 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 #[test]
1428 fn extract_top_level_metadata_skips_huge_messages_array() {
1429 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 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 fn write_session_with_updated_at(
1494 manager: &SessionManager,
1495 id: &str,
1496 updated_at: DateTime<Utc>,
1497 ) {
1498 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 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 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 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 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 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}