Skip to main content

navi_core/
session.rs

1use crate::event::AgentEvent;
2use crate::security::{redact_memory, redact_snapshot_events};
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::Read;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9use tokio::task;
10
11/// Unique identifier for a session, wrapping a string id like `"session-1719612345000"`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionId(String);
14
15impl SessionId {
16    /// Creates a new `SessionId` from the given string.
17    pub fn new(id: String) -> Self {
18        Self(id)
19    }
20
21    /// Returns the id as a string slice.
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25
26    /// Consumes the id and returns the inner string.
27    pub fn into_inner(self) -> String {
28        self.0
29    }
30}
31
32/// Accumulated session memory for a project, used to inject past context into new sessions.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ProjectMemory {
35    /// Hash identifying the project directory.
36    pub project_hash: String,
37    /// Ordered memory entries from past sessions.
38    pub entries: Vec<MemoryEntry>,
39}
40
41/// A single memory entry from a completed session.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct MemoryEntry {
44    /// Unix timestamp (seconds) when this entry was created.
45    pub created_at: u64,
46    /// Short summary of what the session covered.
47    pub summary: String,
48    /// Identifier of the originating session.
49    pub session_id: String,
50}
51
52/// Derives a short title from session events by looking for a markdown heading
53/// in the first model output, falling back to a cleaned version of the first
54/// user task text.
55///
56/// Returns `None` if no suitable text is found.
57pub fn session_title_from_events(events: &[AgentEvent]) -> Option<String> {
58    events
59        .iter()
60        .find_map(|event| match event {
61            AgentEvent::ModelOutput { text, .. } => title_from_model_text(text),
62            _ => None,
63        })
64        .or_else(|| {
65            events.iter().find_map(|event| match event {
66                AgentEvent::UserTaskSubmitted { text, .. } => title_from_user_text(text),
67                _ => None,
68            })
69        })
70}
71
72fn title_from_model_text(text: &str) -> Option<String> {
73    let heading = text.lines().find_map(|line| {
74        let trimmed = line.trim();
75        if trimmed.starts_with('#') {
76            Some(trimmed.trim_start_matches('#').trim())
77        } else {
78            None
79        }
80    });
81
82    heading
83        .and_then(clean_session_title)
84        .or_else(|| text.lines().find_map(clean_session_title))
85}
86
87fn title_from_user_text(text: &str) -> Option<String> {
88    clean_session_title(text)
89}
90
91/// Sanitizes text into a short session title by trimming whitespace, quotes,
92/// markdown markers, and truncating to 80 characters.
93///
94/// Returns `None` if the cleaned text is empty.
95pub fn clean_session_title(text: &str) -> Option<String> {
96    let cleaned = text
97        .trim()
98        .trim_matches('`')
99        .trim_matches('"')
100        .trim_matches('\'')
101        .trim_start_matches(['#', '-', '*', '>'])
102        .split_whitespace()
103        .collect::<Vec<_>>()
104        .join(" ");
105
106    if cleaned.is_empty() {
107        return None;
108    }
109
110    Some(
111        cleaned
112            .chars()
113            .take(80)
114            .collect::<String>()
115            .trim()
116            .to_string(),
117    )
118}
119
120impl ProjectMemory {
121    /// Returns at most `max` of the most recent memory entries.
122    pub fn recent_entries(&self, max: usize) -> &[MemoryEntry] {
123        let start = self.entries.len().saturating_sub(max);
124        &self.entries[start..]
125    }
126
127    /// Formats up to `max` recent entries into a text block suitable for
128    /// injection into the system prompt. Returns `None` if there are no entries.
129    pub fn format_injection(&self, max: usize) -> Option<String> {
130        let entries = self.recent_entries(max);
131        if entries.is_empty() {
132            return None;
133        }
134        let mut parts = Vec::new();
135        for entry in entries {
136            parts.push(format!(
137                "[Session {} — {}]\n{}",
138                entry.session_id,
139                format_timestamp(entry.created_at),
140                entry.summary
141            ));
142        }
143        Some(format!(
144            "Previous session context (summarized):\n\n{}",
145            parts.join("\n\n")
146        ))
147    }
148}
149
150fn format_timestamp(unix_secs: u64) -> String {
151    let days = unix_secs / 86400;
152    let hours = (unix_secs % 86400) / 3600;
153    let minutes = (unix_secs % 3600) / 60;
154    format!("day {days} {hours:02}:{minutes:02}")
155}
156
157fn project_hash(project_dir: &Path) -> String {
158    use std::hash::{Hash, Hasher};
159    let mut hasher = std::collections::hash_map::DefaultHasher::new();
160    project_dir.hash(&mut hasher);
161    format!("{:016x}", hasher.finish())
162}
163
164/// Persists [`SessionSnapshot`] JSON files to disk under `<data_dir>/sessions/`.
165///
166/// By default, secret redaction is enabled so API keys and tokens are scrubbed
167/// from saved event text.
168#[derive(Debug, Clone)]
169pub struct SessionStore {
170    root: PathBuf,
171    data_dir: PathBuf,
172    redact_secrets: bool,
173}
174
175fn default_session_version() -> u32 {
176    1
177}
178
179/// Accumulated token/cost usage for a session (persisted with the snapshot).
180#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
181pub struct SessionUsageSnapshot {
182    /// Cumulative prompt/context tokens billed this session.
183    #[serde(default)]
184    pub input_tokens: u64,
185    /// Cumulative completion tokens billed this session.
186    #[serde(default)]
187    pub output_tokens: u64,
188    /// Estimated spend in USD from list rates × tokens (when known).
189    #[serde(default)]
190    pub cost_usd: f64,
191    /// True once at least one turn had usable list pricing.
192    #[serde(default)]
193    pub cost_known: bool,
194    /// Estimated prepaid credits spent (e.g. Hypercredits = USD / $0.05).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub credits_spent: Option<f64>,
197    /// Credit unit label when `credits_spent` is set (e.g. `hypercredits`).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub credit_unit: Option<String>,
200}
201
202/// A serializable snapshot of a complete session, persisted to disk as JSON.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct SessionSnapshot {
205    /// Snapshot schema version; currently `1`.
206    #[serde(default = "default_session_version")]
207    pub version: u32,
208    /// Unique session identifier.
209    pub id: SessionId,
210    /// Short human-readable title, derived from the first user/assistant message.
211    #[serde(default)]
212    pub title: Option<String>,
213    /// Project directory this session belongs to.
214    pub project: PathBuf,
215    /// Unix timestamp (seconds) when the session was created.
216    #[serde(default)]
217    pub created_at: u64,
218    /// Unix timestamp (seconds) when the session was last updated.
219    #[serde(default)]
220    pub updated_at: u64,
221    /// All agent events recorded during the session.
222    pub events: Vec<AgentEvent>,
223    /// Optional project memory snapshot co-persisted with the session.
224    #[serde(default)]
225    pub memory: Option<ProjectMemory>,
226    /// Optional session goal co-persisted with the session.
227    #[serde(default)]
228    pub goal: Option<SessionGoal>,
229    /// Token and estimated cost usage for this session (restored on reload).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub usage: Option<SessionUsageSnapshot>,
232}
233
234/// Lightweight metadata for listing saved sessions without loading event history.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct SessionSnapshotInfo {
237    /// Unique session identifier.
238    pub id: SessionId,
239    /// Short human-readable title, derived when the snapshot was saved.
240    #[serde(default)]
241    pub title: Option<String>,
242    /// Project directory this session belongs to.
243    pub project: PathBuf,
244    /// Unix timestamp (seconds) when the session was created.
245    #[serde(default)]
246    pub created_at: u64,
247    /// Unix timestamp (seconds) when the session was last updated.
248    #[serde(default)]
249    pub updated_at: u64,
250}
251
252impl SessionSnapshot {
253    /// Current snapshot schema version.
254    pub const CURRENT_VERSION: u32 = 1;
255}
256
257/// True only for persisted session snapshots (`{session_id}.json`).
258///
259/// Rejects desktop UI paint sidecars (`{session_id}.ui.json`) and other
260/// auxiliary `*.json` files that share the sessions directory. Without this,
261/// UIs list ghost sessions that fail to load.
262fn is_session_snapshot_file(path: &Path) -> bool {
263    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
264        return false;
265    };
266    if !name.ends_with(".json") || name.ends_with(".ui.json") {
267        return false;
268    }
269    // e.g. foo.json.tmp written mid-save
270    if name.contains(".tmp") || name.ends_with(".bak") {
271        return false;
272    }
273    path.extension().and_then(|e| e.to_str()) == Some("json")
274}
275
276fn read_session_info(path: &Path) -> Result<SessionSnapshotInfo> {
277    const METADATA_READ_LIMIT: usize = 64 * 1024;
278
279    let mut file =
280        fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
281    let mut buffer = vec![0; METADATA_READ_LIMIT];
282    let bytes_read = file
283        .read(&mut buffer)
284        .with_context(|| format!("failed to read {}", path.display()))?;
285    buffer.truncate(bytes_read);
286
287    let prefix = std::str::from_utf8(&buffer)
288        .with_context(|| format!("failed to decode metadata prefix from {}", path.display()))?;
289
290    if let Some(events_index) = prefix.find("\"events\"")
291        && let Some(comma_index) = prefix[..events_index].rfind(',')
292    {
293        let metadata_json = format!("{}\n}}", &prefix[..comma_index]);
294        return serde_json::from_str::<SessionSnapshotInfo>(&metadata_json)
295            .with_context(|| format!("failed to parse metadata from {}", path.display()));
296    }
297
298    let content =
299        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
300    serde_json::from_str::<SessionSnapshotInfo>(&content)
301        .with_context(|| format!("failed to parse metadata from {}", path.display()))
302}
303
304impl SessionStore {
305    /// Creates a new store with secret redaction enabled.
306    pub fn new(data_dir: PathBuf) -> Self {
307        Self::with_redaction(data_dir, true)
308    }
309
310    /// Creates a new store with the given redaction setting.
311    pub fn with_redaction(data_dir: PathBuf, redact_secrets: bool) -> Self {
312        Self {
313            root: data_dir.join("sessions"),
314            data_dir,
315            redact_secrets,
316        }
317    }
318
319    /// Returns the directory where session JSON files are stored.
320    pub fn root(&self) -> &PathBuf {
321        &self.root
322    }
323
324    /// Generates a new `SessionId` based on the current Unix timestamp in milliseconds.
325    pub fn create_id() -> SessionId {
326        let millis = current_unix_millis();
327        SessionId::new(format!("session-{millis}"))
328    }
329
330    /// Serializes and saves a snapshot to disk, creating the sessions directory
331    /// if needed. Applies secret redaction unless disabled.
332    ///
333    /// This is the blocking implementation used internally and in tests. Use
334    /// [`Self::save_async`] from async contexts to avoid blocking the Tokio
335    /// runtime.
336    pub fn save(&self, snapshot: &SessionSnapshot) -> Result<PathBuf> {
337        fs::create_dir_all(&self.root)
338            .with_context(|| format!("failed to create {}", self.root.display()))?;
339        crate::fs_util::set_private_dir_permissions(&self.root)?;
340
341        let path = self.root.join(format!("{}.json", snapshot.id.as_str()));
342        let snapshot = if self.redact_secrets {
343            SessionSnapshot {
344                version: snapshot.version,
345                id: snapshot.id.clone(),
346                title: snapshot.title.clone(),
347                project: snapshot.project.clone(),
348                created_at: snapshot.created_at,
349                updated_at: snapshot.updated_at,
350                goal: snapshot.goal.clone(),
351                events: redact_snapshot_events(&snapshot.events),
352                memory: snapshot.memory.as_ref().map(redact_memory),
353                usage: snapshot.usage.clone(),
354            }
355        } else {
356            snapshot.clone()
357        };
358        let data = serde_json::to_vec_pretty(&snapshot)?;
359        fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
360        crate::fs_util::set_private_file_permissions(&path)?;
361
362        Ok(path)
363    }
364
365    /// Async wrapper around [`Self::save`] that runs the blocking filesystem
366    /// operations on the Tokio blocking thread pool.
367    pub async fn save_async(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
368        let store = self.clone();
369        task::spawn_blocking(move || store.save(&snapshot))
370            .await
371            .map_err(|err| anyhow::anyhow!("save_async join error: {err}"))?
372    }
373
374    /// Loads all saved sessions from disk, sorted by most recently updated first.
375    pub fn list(&self) -> Vec<SessionSnapshot> {
376        let mut sessions = Vec::new();
377        if let Ok(entries) = fs::read_dir(&self.root) {
378            for entry in entries.flatten() {
379                let path = entry.path();
380                // Only real session snapshots (`{id}.json`). Desktop may write
381                // `{id}.ui.json` paint caches next to them — those must not list.
382                if !is_session_snapshot_file(&path) {
383                    continue;
384                }
385                if let Ok(content) = fs::read_to_string(&path)
386                    && let Ok(snapshot) = serde_json::from_str::<SessionSnapshot>(&content)
387                {
388                    sessions.push(snapshot);
389                }
390            }
391        }
392        sessions.sort_by(|a, b| {
393            b.updated_at
394                .cmp(&a.updated_at)
395                .then_with(|| b.id.as_str().cmp(a.id.as_str()))
396        });
397        sessions
398    }
399
400    /// Loads only session metadata from disk, sorted by most recently updated first.
401    pub fn list_info(&self) -> Vec<SessionSnapshotInfo> {
402        let mut sessions = Vec::new();
403        if let Ok(entries) = fs::read_dir(&self.root) {
404            for entry in entries.flatten() {
405                let path = entry.path();
406                if !is_session_snapshot_file(&path) {
407                    continue;
408                }
409                if let Ok(info) = read_session_info(&path) {
410                    sessions.push(info);
411                }
412            }
413        }
414        sessions.sort_by(|a, b| {
415            b.updated_at
416                .cmp(&a.updated_at)
417                .then_with(|| b.id.as_str().cmp(a.id.as_str()))
418        });
419        sessions
420    }
421
422    /// Async wrapper around [`Self::list`] that runs the blocking filesystem
423    /// operations on the Tokio blocking thread pool.
424    pub async fn list_async(&self) -> Vec<SessionSnapshot> {
425        let store = self.clone();
426        task::spawn_blocking(move || store.list())
427            .await
428            .unwrap_or_default()
429    }
430
431    /// Async wrapper around [`Self::list_info`] that avoids blocking the async runtime.
432    pub async fn list_info_async(&self) -> Vec<SessionSnapshotInfo> {
433        let store = self.clone();
434        task::spawn_blocking(move || store.list_info())
435            .await
436            .unwrap_or_default()
437    }
438
439    /// Loads a single session by id. Returns an error if the file is missing or
440    /// the snapshot version is newer than supported.
441    pub fn load(&self, session_id: &str) -> Result<SessionSnapshot> {
442        let path = self.root.join(format!("{session_id}.json"));
443        let content = fs::read_to_string(&path)
444            .with_context(|| format!("failed to read {}", path.display()))?;
445        let snapshot: SessionSnapshot = serde_json::from_str(&content)
446            .with_context(|| format!("failed to parse {}", path.display()))?;
447        if snapshot.version > SessionSnapshot::CURRENT_VERSION {
448            return Err(anyhow::anyhow!(
449                "session snapshot version {} is newer than supported version {}",
450                snapshot.version,
451                SessionSnapshot::CURRENT_VERSION
452            ));
453        }
454        Ok(snapshot)
455    }
456
457    /// Async wrapper around [`Self::load`] that runs the blocking filesystem
458    /// operations on the Tokio blocking thread pool.
459    pub async fn load_async(&self, session_id: String) -> Result<SessionSnapshot> {
460        let store = self.clone();
461        task::spawn_blocking(move || store.load(&session_id))
462            .await
463            .map_err(|err| anyhow::anyhow!("load_async join error: {err}"))?
464    }
465
466    /// Deletes the session file. Returns `true` if the file existed and was removed.
467    pub fn delete(&self, session_id: &str) -> Result<bool> {
468        let path = self.root.join(format!("{session_id}.json"));
469        match fs::remove_file(&path) {
470            Ok(()) => Ok(true),
471            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
472            Err(err) => Err(err).with_context(|| format!("failed to delete {}", path.display())),
473        }
474    }
475
476    /// Renames a saved session by updating its title field in the snapshot.
477    /// Returns `true` if the session existed and was updated.
478    pub fn rename(&self, session_id: &str, title: &str) -> Result<bool> {
479        let title = title.trim();
480        if title.is_empty() {
481            return Err(anyhow::anyhow!("session title cannot be empty"));
482        }
483        let path = self.root.join(format!("{session_id}.json"));
484        if !path.exists() {
485            return Ok(false);
486        }
487        let mut snapshot = self.load(session_id)?;
488        snapshot.title = Some(title.to_string());
489        snapshot.updated_at = current_unix_timestamp();
490        self.save(&snapshot)?;
491        Ok(true)
492    }
493
494    /// Async wrapper around [`Self::rename`].
495    pub async fn rename_async(&self, session_id: String, title: String) -> Result<bool> {
496        let store = self.clone();
497        task::spawn_blocking(move || store.rename(&session_id, &title))
498            .await
499            .map_err(|err| anyhow::anyhow!("rename_async join error: {err}"))?
500    }
501
502    /// Async wrapper around [`Self::delete`] that runs the blocking filesystem
503    /// operations on the Tokio blocking thread pool.
504    pub async fn delete_async(&self, session_id: String) -> Result<bool> {
505        let store = self.clone();
506        task::spawn_blocking(move || store.delete(&session_id))
507            .await
508            .map_err(|err| anyhow::anyhow!("delete_async join error: {err}"))?
509    }
510
511    /// Persists project memory to `<data_dir>/memory/<hash>.json`.
512    pub fn save_memory(&self, project_dir: &Path, memory: &ProjectMemory) -> Result<PathBuf> {
513        let memory_dir = self.data_dir.join("memory");
514        fs::create_dir_all(&memory_dir)
515            .with_context(|| format!("failed to create {}", memory_dir.display()))?;
516        crate::fs_util::set_private_dir_permissions(&memory_dir)?;
517
518        let hash = project_hash(project_dir);
519        let path = memory_dir.join(format!("{hash}.json"));
520        let data = serde_json::to_vec_pretty(memory)?;
521        fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
522        crate::fs_util::set_private_file_permissions(&path)?;
523
524        Ok(path)
525    }
526
527    /// Async wrapper around [`Self::save_memory`] that runs the blocking
528    /// filesystem operations on the Tokio blocking thread pool.
529    pub async fn save_memory_async(
530        &self,
531        project_dir: PathBuf,
532        memory: ProjectMemory,
533    ) -> Result<PathBuf> {
534        let store = self.clone();
535        task::spawn_blocking(move || store.save_memory(&project_dir, &memory))
536            .await
537            .map_err(|err| anyhow::anyhow!("save_memory_async join error: {err}"))?
538    }
539
540    /// Loads project memory from disk, returning `None` if no memory file exists.
541    pub fn load_memory(&self, project_dir: &Path) -> Option<ProjectMemory> {
542        let hash = project_hash(project_dir);
543        let path = self.data_dir.join("memory").join(format!("{hash}.json"));
544        let content = fs::read_to_string(&path).ok()?;
545        match serde_json::from_str(&content) {
546            Ok(memory) => Some(memory),
547            Err(err) => {
548                tracing::warn!(
549                    path = %path.display(),
550                    error = %err,
551                    "failed to parse project memory file"
552                );
553                None
554            }
555        }
556    }
557
558    /// Async wrapper around [`Self::load_memory`] that runs the blocking
559    /// filesystem operations on the Tokio blocking thread pool.
560    pub async fn load_memory_async(&self, project_dir: PathBuf) -> Option<ProjectMemory> {
561        let store = self.clone();
562        task::spawn_blocking(move || store.load_memory(&project_dir))
563            .await
564            .ok()
565            .flatten()
566    }
567
568    /// Appends a new memory entry for the project and persists it to disk.
569    pub fn add_memory_entry(
570        &self,
571        project_dir: &Path,
572        session_id: &SessionId,
573        summary: String,
574    ) -> Result<PathBuf> {
575        let hash = project_hash(project_dir);
576        let path = self.data_dir.join("memory").join(format!("{hash}.json"));
577
578        // Retry loop to handle concurrent writes from other NAVI instances.
579        // If the file changes between load and save (detected via mtime), we
580        // reload and retry instead of overwriting and losing entries.
581        for _attempt in 0..3 {
582            let mtime_before = fs::metadata(&path).ok().and_then(|m| m.modified().ok());
583            let mut memory = self.load_memory(project_dir).unwrap_or(ProjectMemory {
584                project_hash: project_hash(project_dir),
585                entries: Vec::new(),
586            });
587            memory.entries.push(crate::session::MemoryEntry {
588                created_at: current_unix_timestamp(),
589                summary: summary.clone(),
590                session_id: session_id.as_str().to_string(),
591            });
592            let data = serde_json::to_vec_pretty(&memory)?;
593
594            // Check if the file changed while we were preparing
595            let mtime_after = fs::metadata(&path).ok().and_then(|m| m.modified().ok());
596            if mtime_before != mtime_after && mtime_before.is_some() {
597                // File was modified by another process — retry
598                std::thread::sleep(std::time::Duration::from_millis(50));
599                continue;
600            }
601
602            // Atomic write via temp file + rename
603            if let Some(parent) = path.parent() {
604                if !parent.exists() {
605                    fs::create_dir_all(parent)?;
606                }
607            }
608            let tmp = path.with_extension("json.tmp");
609            fs::write(&tmp, data)?;
610            fs::rename(&tmp, &path)?;
611            crate::fs_util::set_private_file_permissions(&path)?;
612            return Ok(path);
613        }
614        anyhow::bail!("failed to add memory entry after 3 retries (concurrent write conflict)");
615    }
616
617    /// Async wrapper around [`Self::add_memory_entry`] that runs the blocking
618    /// filesystem operations on the Tokio blocking thread pool.
619    pub async fn add_memory_entry_async(
620        &self,
621        project_dir: PathBuf,
622        session_id: String,
623        summary: String,
624    ) -> Result<PathBuf> {
625        let store = self.clone();
626        task::spawn_blocking(move || {
627            let sid = SessionId::new(session_id);
628            store.add_memory_entry(&project_dir, &sid, summary)
629        })
630        .await
631        .map_err(|err| anyhow::anyhow!("add_memory_entry_async join error: {err}"))?
632    }
633}
634
635/// Returns the current time as a Unix timestamp in seconds.
636pub fn current_unix_timestamp() -> u64 {
637    SystemTime::now()
638        .duration_since(UNIX_EPOCH)
639        .map(|duration| duration.as_secs())
640        .unwrap_or_default()
641}
642
643fn current_unix_millis() -> u128 {
644    SystemTime::now()
645        .duration_since(UNIX_EPOCH)
646        .map(|duration| duration.as_millis())
647        .unwrap_or_default()
648}
649
650use crate::goal::types::SessionGoal;
651use crate::model::ContentPart;
652
653/// A user task submission sent to the session background loop.
654pub struct Submission {
655    /// The user's task text.
656    pub task: String,
657    /// Optional multimodal content parts (images + text).
658    /// When non-empty, the session loop creates a multimodal user message.
659    pub content_parts: Vec<ContentPart>,
660    /// Channel to send the assistant's response back to the caller.
661    pub response_tx: tokio::sync::oneshot::Sender<Result<String>>,
662}
663
664/// Commands accepted by the session background loop.
665pub enum SessionCommand {
666    /// Run a full agent turn for a user message.
667    Turn(Submission),
668    /// Drop conversation history after `keep_user_turns` user messages
669    /// (and the assistant/tool messages belonging to those turns).
670    /// Used when the UI edits a past user message and re-sends.
671    TruncateToUserTurns {
672        keep_user_turns: usize,
673        response_tx: tokio::sync::oneshot::Sender<Result<usize>>,
674    },
675}
676
677/// Truncate model history so only the first `keep_user_turns` user turns remain.
678///
679/// System/developer preamble is always kept. The cut point is the start of the
680/// `(keep_user_turns + 1)`-th user message (0-based count of user messages kept).
681pub fn truncate_messages_to_user_turns(
682    messages: &mut Vec<crate::model::ModelMessage>,
683    keep_user_turns: usize,
684) {
685    use crate::model::ModelRole;
686    let mut seen_users = 0usize;
687    let mut cut: Option<usize> = None;
688    for (i, msg) in messages.iter().enumerate() {
689        if msg.role == ModelRole::User {
690            if seen_users == keep_user_turns {
691                cut = Some(i);
692                break;
693            }
694            seen_users += 1;
695        }
696    }
697    if let Some(i) = cut {
698        messages.truncate(i);
699    }
700}
701
702/// A handle to a background session loop that accepts [`SessionCommand`]s and
703/// runs them through the turn pipeline.
704#[derive(Clone)]
705pub struct SessionRuntime {
706    /// Channel for sending commands to the background loop.
707    pub submission_tx: tokio::sync::mpsc::UnboundedSender<SessionCommand>,
708}
709
710impl SessionRuntime {
711    /// Spawns a background tokio task that processes submissions sequentially
712    /// through the turn pipeline, maintaining conversation history.
713    pub fn spawn(
714        ctx: std::sync::Arc<crate::turn::TurnContext>,
715        policy: crate::harness::HarnessPolicy,
716        initial_messages: Vec<crate::model::ModelMessage>,
717        _memory_injection: Option<String>,
718    ) -> Self {
719        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<SessionCommand>();
720
721        tokio::spawn(async move {
722            let mut messages = initial_messages;
723
724            while let Some(command) = rx.recv().await {
725                match command {
726                    SessionCommand::Turn(submission) => {
727                        if submission.content_parts.is_empty() {
728                            messages.push(crate::model::ModelMessage::user(submission.task));
729                        } else {
730                            messages.push(crate::model::ModelMessage::user_multimodal(
731                                submission.task,
732                                submission.content_parts,
733                            ));
734                        }
735                        let res = crate::turn::run_turn(&ctx, &mut messages, policy).await;
736                        let _ = submission.response_tx.send(res);
737                    }
738                    SessionCommand::TruncateToUserTurns {
739                        keep_user_turns,
740                        response_tx,
741                    } => {
742                        truncate_messages_to_user_turns(&mut messages, keep_user_turns);
743                        let _ = response_tx.send(Ok(messages.len()));
744                    }
745                }
746            }
747        });
748
749        Self { submission_tx: tx }
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::tool::{ToolInvocation, ToolResult};
757
758    #[test]
759    fn save_writes_session_snapshot() {
760        let tempdir = tempfile::tempdir().expect("tempdir");
761        let store = SessionStore::new(tempdir.path().to_path_buf());
762        let snapshot = SessionSnapshot {
763            version: SessionSnapshot::CURRENT_VERSION,
764            id: SessionId::new("test-session".to_string()),
765            title: Some("Test session".to_string()),
766            project: PathBuf::from("/tmp/project"),
767            created_at: 1,
768            updated_at: 2,
769            events: Vec::new(),
770            memory: None,
771            goal: None,
772            usage: None,
773        };
774
775        let path = store.save(&snapshot).expect("save session");
776        assert!(path.exists());
777        assert_eq!(path.file_name().unwrap(), "test-session.json");
778    }
779
780    #[cfg(unix)]
781    #[test]
782    fn save_restricts_session_file_and_directory_permissions() {
783        use std::os::unix::fs::PermissionsExt;
784
785        let tempdir = tempfile::tempdir().expect("tempdir");
786        let data_dir = tempdir.path().join("navi-data");
787        let store = SessionStore::new(data_dir);
788        let snapshot = SessionSnapshot {
789            version: SessionSnapshot::CURRENT_VERSION,
790            id: SessionId::new("private-session".to_string()),
791            title: None,
792            project: PathBuf::from("/tmp/project"),
793            created_at: 1,
794            updated_at: 2,
795            events: Vec::new(),
796            memory: None,
797            goal: None,
798            usage: None,
799        };
800
801        let path = store.save(&snapshot).expect("save session");
802        let dir_mode = fs::metadata(store.root())
803            .expect("dir metadata")
804            .permissions()
805            .mode()
806            & 0o777;
807        let file_mode = fs::metadata(path)
808            .expect("file metadata")
809            .permissions()
810            .mode()
811            & 0o777;
812
813        assert_eq!(dir_mode, 0o700);
814        assert_eq!(file_mode, 0o600);
815    }
816
817    #[test]
818    fn save_redacts_secret_like_event_content() {
819        let tempdir = tempfile::tempdir().expect("tempdir");
820        let store = SessionStore::new(tempdir.path().to_path_buf());
821        let snapshot = SessionSnapshot {
822            version: SessionSnapshot::CURRENT_VERSION,
823            id: SessionId::new("redacted-session".to_string()),
824            title: None,
825            project: PathBuf::from("/tmp/project"),
826            created_at: 1,
827            updated_at: 2,
828            events: vec![AgentEvent::UserTaskSubmitted {
829                text: "OPENAI_API_KEY=sk-proj-1234567890abcdef".to_string(),
830                content_parts: vec![],
831                submitted_at: None,
832            }],
833            memory: None,
834            goal: None,
835            usage: None,
836        };
837
838        let path = store.save(&snapshot).expect("save session");
839        let content = fs::read_to_string(path).expect("read session");
840
841        assert!(content.contains("OPENAI_API_KEY=<redacted>"));
842        assert!(!content.contains("sk-proj-1234567890abcdef"));
843    }
844
845    #[test]
846    fn save_redacts_secret_like_memory_summaries() {
847        let tempdir = tempfile::tempdir().expect("tempdir");
848        let store = SessionStore::new(tempdir.path().to_path_buf());
849        let snapshot = SessionSnapshot {
850            version: SessionSnapshot::CURRENT_VERSION,
851            id: SessionId::new("redacted-memory-session".to_string()),
852            title: None,
853            project: PathBuf::from("/tmp/project"),
854            created_at: 1,
855            updated_at: 2,
856            events: Vec::new(),
857            memory: Some(ProjectMemory {
858                project_hash: "abc".to_string(),
859                entries: vec![MemoryEntry {
860                    created_at: 1_700_000_000,
861                    summary: "Configured with OPENAI_API_KEY=sk-proj-abcdef0123456789".to_string(),
862                    session_id: "session-x".to_string(),
863                }],
864            }),
865            goal: None,
866            usage: None,
867        };
868
869        let path = store.save(&snapshot).expect("save session");
870        let content = fs::read_to_string(path).expect("read session");
871
872        assert!(content.contains("OPENAI_API_KEY=<redacted>"));
873        assert!(!content.contains("sk-proj-abcdef0123456789"));
874    }
875
876    #[test]
877    fn save_can_preserve_event_content_when_redaction_is_disabled() {
878        let tempdir = tempfile::tempdir().expect("tempdir");
879        let store = SessionStore::with_redaction(tempdir.path().to_path_buf(), false);
880        let snapshot = SessionSnapshot {
881            version: SessionSnapshot::CURRENT_VERSION,
882            id: SessionId::new("unredacted-session".to_string()),
883            title: None,
884            project: PathBuf::from("/tmp/project"),
885            created_at: 1,
886            updated_at: 2,
887            events: vec![AgentEvent::UserTaskSubmitted {
888                text: "OPENAI_API_KEY=sk-proj-1234567890abcdef".to_string(),
889                content_parts: vec![],
890                submitted_at: None,
891            }],
892            memory: None,
893            goal: None,
894            usage: None,
895        };
896
897        let path = store.save(&snapshot).expect("save session");
898        let content = fs::read_to_string(path).expect("read session");
899
900        assert!(content.contains("sk-proj-1234567890abcdef"));
901    }
902
903    struct MockProvider;
904
905    #[async_trait::async_trait]
906    impl crate::model::ModelProvider for MockProvider {
907        fn stream(&self, _request: crate::model::ModelRequest) -> crate::model::ModelStream {
908            Box::pin(futures_util::stream::iter(vec![
909                Ok(crate::model::ModelStreamEvent::TextDelta {
910                    text: "mock task response".to_string(),
911                }),
912                Ok(crate::model::ModelStreamEvent::Done),
913            ]))
914        }
915    }
916
917    #[tokio::test]
918    async fn test_session_runtime_background_loop() {
919        let tempdir = tempfile::tempdir().unwrap();
920        let security_policy = crate::SecurityPolicy::new(
921            tempdir.path().to_path_buf(),
922            tempdir.path().to_path_buf(),
923            crate::SecurityConfig::default(),
924        )
925        .unwrap();
926        let tool_executor = std::sync::Arc::new(crate::ToolExecutor::new(security_policy));
927
928        let ctx = std::sync::Arc::new(crate::turn::TurnContext {
929            model_provider: std::sync::Arc::new(std::sync::RwLock::new(std::sync::Arc::new(
930                MockProvider,
931            ))),
932            tool_executor,
933            project_dir: tempdir.path().to_path_buf(),
934            data_dir: tempdir.path().join("data"),
935            model_name: std::sync::Arc::new(std::sync::RwLock::new("test-model".to_string())),
936            event_tx: None,
937            approval_resolver: crate::runtime::ApprovalResolver::new_for_test(),
938            question_resolver: crate::runtime::QuestionResolver::new_for_test(),
939            plan_review_resolver: crate::runtime::PlanReviewResolver::new_for_test(),
940            sudo_password_resolver: crate::runtime::SudoPasswordResolver::new_for_test(),
941            compact_state: std::sync::Arc::new(tokio::sync::Mutex::new(
942                crate::compact::CompactState::new(128_000),
943            )),
944            harness_config: crate::config::HarnessConfig::default(),
945            include_tool_prompt_manifest: false,
946            context_packets: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
947            available_skills: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
948            active_skills: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
949            prompt_cache: std::sync::Arc::new(crate::prompt::PromptCache::new()),
950            instructions: std::sync::Arc::new(std::sync::RwLock::new(None)),
951            prompt_prefix: std::sync::Arc::new(std::sync::Mutex::new(None)),
952            components: crate::RuntimeComponents::default(),
953            cancel_token: crate::cancel::CancelToken::new(),
954            config: std::sync::Arc::new(std::sync::RwLock::new(
955                crate::config::NaviConfig::default(),
956            )),
957            memory_injection: None,
958            compaction_provider: None,
959            agent_mode: crate::plan_mode::AgentMode::Default,
960            compaction_model_name: None,
961            session_id: "test-session".to_string(),
962            allowed_tool_names: None,
963            memory_manager: std::sync::Arc::new(std::sync::Mutex::new(None)),
964        });
965
966        let policy = crate::harness::policy_for_profile(
967            &crate::config::HarnessConfig {
968                observation_bytes_small: 1000,
969                ..crate::config::HarnessConfig::default()
970            },
971            crate::config::HarnessProfile::Small,
972        );
973
974        let runtime = SessionRuntime::spawn(ctx, policy, Vec::new(), None);
975
976        let (tx, rx) = tokio::sync::oneshot::channel();
977        let submission = SessionCommand::Turn(Submission {
978            task: "hello world".to_string(),
979            content_parts: Vec::new(),
980            response_tx: tx,
981        });
982
983        runtime.submission_tx.send(submission).unwrap();
984
985        let result = rx.await.unwrap().unwrap();
986        assert_eq!(result, "mock task response");
987    }
988
989    #[test]
990    fn truncate_messages_keeps_preamble_and_prior_turns() {
991        use crate::model::{ModelMessage, ModelRole};
992        let mut messages = vec![
993            ModelMessage::system("sys"),
994            ModelMessage::developer("dev"),
995            ModelMessage::user("u1"),
996            ModelMessage {
997                role: ModelRole::Assistant,
998                content: "a1".into(),
999                content_parts: vec![],
1000                tool_call_id: None,
1001                tool_name: None,
1002                tool_calls: vec![],
1003                created_at: None,
1004                thinking_content: None,
1005            },
1006            ModelMessage::user("u2"),
1007            ModelMessage {
1008                role: ModelRole::Assistant,
1009                content: "a2".into(),
1010                content_parts: vec![],
1011                tool_call_id: None,
1012                tool_name: None,
1013                tool_calls: vec![],
1014                created_at: None,
1015                thinking_content: None,
1016            },
1017            ModelMessage::user("u3"),
1018        ];
1019        truncate_messages_to_user_turns(&mut messages, 1);
1020        assert_eq!(messages.len(), 4);
1021        assert_eq!(messages[2].content, "u1");
1022        assert_eq!(messages[3].content, "a1");
1023
1024        truncate_messages_to_user_turns(&mut messages, 0);
1025        assert_eq!(messages.len(), 2);
1026        assert!(matches!(messages[0].role, ModelRole::System));
1027        assert!(matches!(messages[1].role, ModelRole::Developer));
1028    }
1029
1030    #[test]
1031    fn project_memory_format_injection_returns_none_when_empty() {
1032        let memory = ProjectMemory {
1033            project_hash: "abc".to_string(),
1034            entries: Vec::new(),
1035        };
1036        assert!(memory.format_injection(3).is_none());
1037    }
1038
1039    #[test]
1040    fn project_memory_format_injection_returns_latest_entries() {
1041        let memory = ProjectMemory {
1042            project_hash: "abc".to_string(),
1043            entries: vec![
1044                MemoryEntry {
1045                    created_at: 1000,
1046                    summary: "First session".to_string(),
1047                    session_id: "session-1".to_string(),
1048                },
1049                MemoryEntry {
1050                    created_at: 2000,
1051                    summary: "Second session".to_string(),
1052                    session_id: "session-2".to_string(),
1053                },
1054                MemoryEntry {
1055                    created_at: 3000,
1056                    summary: "Third session".to_string(),
1057                    session_id: "session-3".to_string(),
1058                },
1059                MemoryEntry {
1060                    created_at: 4000,
1061                    summary: "Fourth session".to_string(),
1062                    session_id: "session-4".to_string(),
1063                },
1064            ],
1065        };
1066        let injection = memory.format_injection(2).unwrap();
1067        assert!(injection.contains("Third session"));
1068        assert!(injection.contains("Fourth session"));
1069        assert!(!injection.contains("First session"));
1070        assert!(!injection.contains("Second session"));
1071    }
1072
1073    #[test]
1074    fn save_and_load_memory_roundtrip() {
1075        let tempdir = tempfile::tempdir().expect("tempdir");
1076        let store = SessionStore::new(tempdir.path().to_path_buf());
1077        let project_dir = PathBuf::from("/tmp/test-project");
1078
1079        let memory = ProjectMemory {
1080            project_hash: project_hash(&project_dir),
1081            entries: vec![MemoryEntry {
1082                created_at: 12345,
1083                summary: "Worked on auth module".to_string(),
1084                session_id: "session-test".to_string(),
1085            }],
1086        };
1087
1088        store
1089            .save_memory(&project_dir, &memory)
1090            .expect("save memory");
1091        let loaded = store.load_memory(&project_dir).expect("load memory");
1092        assert_eq!(loaded.entries.len(), 1);
1093        assert_eq!(loaded.entries[0].summary, "Worked on auth module");
1094    }
1095
1096    #[test]
1097    fn add_memory_entry_appends_to_existing() {
1098        let tempdir = tempfile::tempdir().expect("tempdir");
1099        let store = SessionStore::new(tempdir.path().to_path_buf());
1100        let project_dir = PathBuf::from("/tmp/test-project-2");
1101
1102        let session_id = SessionId::new("session-1".to_string());
1103        store
1104            .add_memory_entry(&project_dir, &session_id, "First summary".to_string())
1105            .expect("add entry 1");
1106
1107        let session_id2 = SessionId::new("session-2".to_string());
1108        store
1109            .add_memory_entry(&project_dir, &session_id2, "Second summary".to_string())
1110            .expect("add entry 2");
1111
1112        let loaded = store.load_memory(&project_dir).expect("load memory");
1113        assert_eq!(loaded.entries.len(), 2);
1114        assert_eq!(loaded.entries[0].summary, "First summary");
1115        assert_eq!(loaded.entries[1].summary, "Second summary");
1116    }
1117
1118    fn make_snapshot(id: &str, updated_at: u64) -> SessionSnapshot {
1119        SessionSnapshot {
1120            version: SessionSnapshot::CURRENT_VERSION,
1121            id: SessionId::new(id.to_string()),
1122            title: Some(format!("Session {id}")),
1123            project: PathBuf::from("/tmp/project"),
1124            created_at: updated_at - 10,
1125            updated_at,
1126            events: Vec::new(),
1127            memory: None,
1128            goal: None,
1129            usage: None,
1130        }
1131    }
1132
1133    #[test]
1134    fn list_returns_sessions_sorted_by_updated_at() {
1135        let tempdir = tempfile::tempdir().expect("tempdir");
1136        let store = SessionStore::with_redaction(tempdir.path().to_path_buf(), false);
1137        store.save(&make_snapshot("s-old", 100)).expect("save");
1138        store.save(&make_snapshot("s-new", 300)).expect("save");
1139        store.save(&make_snapshot("s-mid", 200)).expect("save");
1140
1141        let sessions = store.list();
1142        assert_eq!(sessions.len(), 3);
1143        assert_eq!(sessions[0].id.as_str(), "s-new");
1144        assert_eq!(sessions[1].id.as_str(), "s-mid");
1145        assert_eq!(sessions[2].id.as_str(), "s-old");
1146    }
1147
1148    #[test]
1149    fn list_returns_empty_when_no_sessions() {
1150        let tempdir = tempfile::tempdir().expect("tempdir");
1151        let store = SessionStore::new(tempdir.path().to_path_buf());
1152        assert!(store.list().is_empty());
1153    }
1154
1155    #[test]
1156    fn load_roundtrip_save_then_load() {
1157        let tempdir = tempfile::tempdir().expect("tempdir");
1158        let store = SessionStore::with_redaction(tempdir.path().to_path_buf(), false);
1159        let snapshot = make_snapshot("roundtrip-1", 500);
1160        store.save(&snapshot).expect("save");
1161
1162        let loaded = store.load("roundtrip-1").expect("load");
1163        assert_eq!(loaded.id.as_str(), "roundtrip-1");
1164        assert_eq!(loaded.title, Some("Session roundtrip-1".to_string()));
1165        assert_eq!(loaded.updated_at, 500);
1166    }
1167
1168    #[test]
1169    fn load_rejects_unsupported_version() {
1170        let tempdir = tempfile::tempdir().expect("tempdir");
1171        let store = SessionStore::with_redaction(tempdir.path().to_path_buf(), false);
1172        let mut snapshot = make_snapshot("future-session", 100);
1173        snapshot.version = 999;
1174        store.save(&snapshot).expect("save");
1175
1176        let result = store.load("future-session");
1177        assert!(result.is_err());
1178        let err = result.unwrap_err().to_string();
1179        assert!(err.contains("version"), "expected version error: {err}");
1180    }
1181
1182    #[test]
1183    fn delete_removes_session_file() {
1184        let tempdir = tempfile::tempdir().expect("tempdir");
1185        let store = SessionStore::with_redaction(tempdir.path().to_path_buf(), false);
1186        store.save(&make_snapshot("del-1", 100)).expect("save");
1187        assert!(store.root().join("del-1.json").exists());
1188
1189        let deleted = store.delete("del-1").expect("delete");
1190        assert!(deleted);
1191        assert!(!store.root().join("del-1.json").exists());
1192    }
1193
1194    #[test]
1195    fn delete_returns_false_for_missing() {
1196        let tempdir = tempfile::tempdir().expect("tempdir");
1197        let store = SessionStore::new(tempdir.path().to_path_buf());
1198        let deleted = store.delete("nonexistent").expect("delete");
1199        assert!(!deleted);
1200    }
1201
1202    #[test]
1203    fn session_snapshot_serialization_roundtrip() {
1204        let snapshot = SessionSnapshot {
1205            version: SessionSnapshot::CURRENT_VERSION,
1206            id: SessionId::new("ser-1".to_string()),
1207            title: Some("Test".to_string()),
1208            project: PathBuf::from("/tmp/p"),
1209            created_at: 1000,
1210            updated_at: 2000,
1211            events: vec![
1212                AgentEvent::UserTaskSubmitted {
1213                    text: "hello".to_string(),
1214                    content_parts: vec![],
1215                    submitted_at: None,
1216                },
1217                AgentEvent::ModelOutput {
1218                    text: "response".to_string(),
1219                    thinking: Some("reasoning".to_string()),
1220                },
1221            ],
1222            memory: None,
1223            goal: None,
1224            usage: None,
1225        };
1226        let json = serde_json::to_string(&snapshot).expect("serialize");
1227        let loaded: SessionSnapshot = serde_json::from_str(&json).expect("deserialize");
1228        assert_eq!(loaded.id.as_str(), "ser-1");
1229        assert_eq!(loaded.events.len(), 2);
1230    }
1231
1232    #[test]
1233    fn session_title_from_events_prefers_model_heading() {
1234        let events = vec![
1235            AgentEvent::UserTaskSubmitted {
1236                text: "do something".to_string(),
1237                content_parts: vec![],
1238                submitted_at: None,
1239            },
1240            AgentEvent::ModelOutput {
1241                text: "# My Analysis\n\nSome content here".to_string(),
1242                thinking: None,
1243            },
1244        ];
1245        let title = session_title_from_events(&events);
1246        assert_eq!(title.as_deref(), Some("My Analysis"));
1247    }
1248
1249    #[test]
1250    fn session_title_from_events_falls_back_to_user_text() {
1251        let events = vec![AgentEvent::UserTaskSubmitted {
1252            text: "Fix the bug".to_string(),
1253            content_parts: vec![],
1254            submitted_at: None,
1255        }];
1256        let title = session_title_from_events(&events);
1257        assert_eq!(title.as_deref(), Some("Fix the bug"));
1258    }
1259
1260    #[test]
1261    fn clean_session_title_strips_markdown_and_truncates() {
1262        assert_eq!(clean_session_title("## Short"), Some("Short".to_string()));
1263        assert_eq!(
1264            clean_session_title("`code snippet`"),
1265            Some("code snippet".to_string())
1266        );
1267        let long = "a".repeat(200);
1268        let result = clean_session_title(&long).unwrap();
1269        assert!(result.len() <= 80);
1270    }
1271
1272    #[test]
1273    fn clean_session_title_returns_none_for_empty() {
1274        assert!(clean_session_title("").is_none());
1275        assert!(clean_session_title("###").is_none());
1276    }
1277
1278    #[test]
1279    fn save_and_load_preserves_events() {
1280        let tempdir = tempfile::tempdir().expect("tempdir");
1281        let store = SessionStore::with_redaction(tempdir.path().to_path_buf(), false);
1282        let snapshot = SessionSnapshot {
1283            version: SessionSnapshot::CURRENT_VERSION,
1284            id: SessionId::new("events-session".to_string()),
1285            title: None,
1286            project: PathBuf::from("/tmp/p"),
1287            created_at: 10,
1288            updated_at: 20,
1289            events: vec![
1290                AgentEvent::UserTaskSubmitted {
1291                    text: "task".to_string(),
1292                    content_parts: vec![],
1293                    submitted_at: None,
1294                },
1295                AgentEvent::ToolRequested(ToolInvocation {
1296                    id: "c1".to_string(),
1297                    tool_name: "read_file".to_string(),
1298                    input: serde_json::json!({"path": "x.txt"}),
1299                }),
1300                AgentEvent::ToolCompleted(ToolResult {
1301                    invocation_id: "c1".to_string(),
1302                    ok: true,
1303                    output: serde_json::json!("file content"),
1304                }),
1305            ],
1306            memory: None,
1307            goal: None,
1308            usage: None,
1309        };
1310        store.save(&snapshot).expect("save");
1311        let loaded = store.load("events-session").expect("load");
1312        assert_eq!(loaded.events.len(), 3);
1313    }
1314
1315    // ── Regression tests ──────────────────────────────────────────────────────
1316
1317    #[test]
1318    fn regression_corrupt_json_on_disk_skipped_by_list() {
1319        let tempdir = tempfile::tempdir().expect("tempdir");
1320        let store = SessionStore::new(tempdir.path().to_path_buf());
1321
1322        // Write a valid session
1323        store.save(&make_snapshot("valid", 100)).expect("save");
1324
1325        // Write a corrupt JSON file
1326        let corrupt_path = store.root().join("corrupt.json");
1327        std::fs::write(&corrupt_path, "{invalid json!!!").expect("write corrupt");
1328
1329        // list() should skip the corrupt file and return only the valid one
1330        let sessions = store.list();
1331        assert_eq!(sessions.len(), 1);
1332        assert_eq!(sessions[0].id.as_str(), "valid");
1333    }
1334
1335    #[test]
1336    fn regression_list_ignores_non_json_files() {
1337        let tempdir = tempfile::tempdir().expect("tempdir");
1338        let store = SessionStore::new(tempdir.path().to_path_buf());
1339
1340        store.save(&make_snapshot("valid", 100)).expect("save");
1341
1342        // Write non-json files
1343        std::fs::write(store.root().join("notes.txt"), "not a session").expect("write");
1344        std::fs::write(store.root().join("README.md"), "# readme").expect("write");
1345
1346        let sessions = store.list();
1347        assert_eq!(sessions.len(), 1);
1348    }
1349
1350    #[test]
1351    fn regression_load_missing_version_defaults_to_one() {
1352        let tempdir = tempfile::tempdir().expect("tempdir");
1353        let store = SessionStore::new(tempdir.path().to_path_buf());
1354
1355        // Write a snapshot JSON without the "version" field
1356        // SessionId serializes as a plain string
1357        let json = serde_json::json!({
1358            "id": "no-version",
1359            "title": null,
1360            "project": "/tmp/p",
1361            "created_at": 1,
1362            "updated_at": 2,
1363            "events": [],
1364            "memory": null
1365        });
1366        let path = store.root().join("no-version.json");
1367        std::fs::create_dir_all(store.root()).expect("create sessions dir");
1368        std::fs::write(&path, serde_json::to_string(&json).unwrap()).expect("write");
1369
1370        let loaded = store.load("no-version").expect("load");
1371        assert_eq!(loaded.version, 1); // default_session_version
1372    }
1373
1374    #[test]
1375    fn regression_load_memory_malformed_json_returns_none() {
1376        let tempdir = tempfile::tempdir().expect("tempdir");
1377        let store = SessionStore::new(tempdir.path().to_path_buf());
1378        let project_dir = PathBuf::from("/tmp/test-project");
1379
1380        // Write a corrupt memory file
1381        let hash = {
1382            use std::hash::{Hash, Hasher};
1383            let mut hasher = std::collections::hash_map::DefaultHasher::new();
1384            project_dir.hash(&mut hasher);
1385            format!("{:016x}", hasher.finish())
1386        };
1387        let memory_dir = tempdir.path().join("memory");
1388        std::fs::create_dir_all(&memory_dir).expect("create");
1389        std::fs::write(memory_dir.join(format!("{hash}.json")), "not json!").expect("write");
1390
1391        let loaded = store.load_memory(&project_dir);
1392        assert!(loaded.is_none(), "malformed memory should return None");
1393    }
1394
1395    #[test]
1396    fn regression_session_title_only_tool_events_returns_none() {
1397        let events = vec![
1398            AgentEvent::ToolRequested(ToolInvocation {
1399                id: "c1".to_string(),
1400                tool_name: "read_file".to_string(),
1401                input: serde_json::json!({}),
1402            }),
1403            AgentEvent::ToolCompleted(ToolResult {
1404                invocation_id: "c1".to_string(),
1405                ok: true,
1406                output: serde_json::json!("content"),
1407            }),
1408        ];
1409        let title = session_title_from_events(&events);
1410        assert!(title.is_none(), "no user/model text should return None");
1411    }
1412
1413    #[test]
1414    fn regression_project_hash_is_stable() {
1415        let path = PathBuf::from("/tmp/some/project/dir");
1416        let hash1 = {
1417            use std::hash::{Hash, Hasher};
1418            let mut hasher = std::collections::hash_map::DefaultHasher::new();
1419            path.hash(&mut hasher);
1420            format!("{:016x}", hasher.finish())
1421        };
1422        let hash2 = {
1423            use std::hash::{Hash, Hasher};
1424            let mut hasher = std::collections::hash_map::DefaultHasher::new();
1425            path.hash(&mut hasher);
1426            format!("{:016x}", hasher.finish())
1427        };
1428        assert_eq!(hash1, hash2);
1429    }
1430
1431    #[test]
1432    fn regression_create_id_format() {
1433        let id = SessionStore::create_id();
1434        assert!(
1435            id.as_str().starts_with("session-"),
1436            "session id must start with 'session-'"
1437        );
1438    }
1439}