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#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionId(String);
14
15impl SessionId {
16 pub fn new(id: String) -> Self {
18 Self(id)
19 }
20
21 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25
26 pub fn into_inner(self) -> String {
28 self.0
29 }
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ProjectMemory {
35 pub project_hash: String,
37 pub entries: Vec<MemoryEntry>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct MemoryEntry {
44 pub created_at: u64,
46 pub summary: String,
48 pub session_id: String,
50}
51
52pub 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
91pub 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 pub fn recent_entries(&self, max: usize) -> &[MemoryEntry] {
123 let start = self.entries.len().saturating_sub(max);
124 &self.entries[start..]
125 }
126
127 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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
181pub struct SessionUsageSnapshot {
182 #[serde(default)]
184 pub input_tokens: u64,
185 #[serde(default)]
187 pub output_tokens: u64,
188 #[serde(default)]
190 pub cost_usd: f64,
191 #[serde(default)]
193 pub cost_known: bool,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub credits_spent: Option<f64>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub credit_unit: Option<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct SessionSnapshot {
205 #[serde(default = "default_session_version")]
207 pub version: u32,
208 pub id: SessionId,
210 #[serde(default)]
212 pub title: Option<String>,
213 pub project: PathBuf,
215 #[serde(default)]
217 pub created_at: u64,
218 #[serde(default)]
220 pub updated_at: u64,
221 pub events: Vec<AgentEvent>,
223 #[serde(default)]
225 pub memory: Option<ProjectMemory>,
226 #[serde(default)]
228 pub goal: Option<SessionGoal>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub usage: Option<SessionUsageSnapshot>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct SessionSnapshotInfo {
237 pub id: SessionId,
239 #[serde(default)]
241 pub title: Option<String>,
242 pub project: PathBuf,
244 #[serde(default)]
246 pub created_at: u64,
247 #[serde(default)]
249 pub updated_at: u64,
250}
251
252impl SessionSnapshot {
253 pub const CURRENT_VERSION: u32 = 1;
255}
256
257fn 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 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 pub fn new(data_dir: PathBuf) -> Self {
307 Self::with_redaction(data_dir, true)
308 }
309
310 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 pub fn root(&self) -> &PathBuf {
321 &self.root
322 }
323
324 pub fn create_id() -> SessionId {
326 let millis = current_unix_millis();
327 SessionId::new(format!("session-{millis}"))
328 }
329
330 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mtime_after = fs::metadata(&path).ok().and_then(|m| m.modified().ok());
596 if mtime_before != mtime_after && mtime_before.is_some() {
597 std::thread::sleep(std::time::Duration::from_millis(50));
599 continue;
600 }
601
602 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 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
635pub 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
653pub struct Submission {
655 pub task: String,
657 pub content_parts: Vec<ContentPart>,
660 pub response_tx: tokio::sync::oneshot::Sender<Result<String>>,
662}
663
664pub enum SessionCommand {
666 Turn(Submission),
668 TruncateToUserTurns {
672 keep_user_turns: usize,
673 response_tx: tokio::sync::oneshot::Sender<Result<usize>>,
674 },
675}
676
677pub 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#[derive(Clone)]
705pub struct SessionRuntime {
706 pub submission_tx: tokio::sync::mpsc::UnboundedSender<SessionCommand>,
708}
709
710impl SessionRuntime {
711 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 #[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 store.save(&make_snapshot("valid", 100)).expect("save");
1324
1325 let corrupt_path = store.root().join("corrupt.json");
1327 std::fs::write(&corrupt_path, "{invalid json!!!").expect("write corrupt");
1328
1329 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 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 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); }
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 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}