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 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 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 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 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 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 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 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 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 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 sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
414
415 Ok(sessions)
416 }
417
418 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 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 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 fn cleanup_old_sessions(&self) -> std::io::Result<()> {
475 let sessions = self.list_sessions()?;
476
477 if sessions.len() > MAX_SESSIONS {
478 for session in sessions.iter().skip(MAX_SESSIONS) {
480 let _ = self.delete_session(&session.id);
481 }
482 }
483
484 Ok(())
485 }
486
487 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 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 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, ¤t_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
587pub 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
597pub 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
613pub 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
631pub 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 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
681pub 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
702fn 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
721fn strip_thinking_blocks(messages: &mut [Message]) {
728 for msg in messages {
729 msg.content
730 .retain(|block| !matches!(block, ContentBlock::Thinking { .. }));
731 }
732}
733
734fn 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
744fn 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 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 !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 let after_key = key_offset + key_pat.len();
794 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 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
862pub fn truncate_id(id: &str) -> &str {
865 id.get(..8).unwrap_or(id)
866}
867
868fn 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
882pub 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
896fn 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
914fn sqlite_to_io<T>(r: anyhow::Result<T>) -> std::io::Result<T> {
917 r.map_err(|e| {
918 let msg = format!("{e:#}");
919 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#[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 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 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 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 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 #[test]
1392 fn extract_top_level_metadata_skips_huge_messages_array() {
1393 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 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 fn write_session_with_updated_at(
1458 manager: &SessionManager,
1459 id: &str,
1460 updated_at: DateTime<Utc>,
1461 ) {
1462 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 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 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 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 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 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}