Skip to main content

lean_ctx/core/session/
mod.rs

1mod compaction;
2mod heuristics;
3mod paths;
4mod persistence;
5pub mod playbook;
6mod state;
7mod types;
8
9pub use playbook::{DeltaOutcome, EntryKind, Playbook, PlaybookEntry};
10pub use types::{
11    Decision, EvidenceKind, EvidenceRecord, FileTouched, Finding, ManifestEntry, PreparedSave,
12    ProgressEntry, SessionState, SessionStats, SessionSummary, TaskInfo, TestSnapshot,
13};
14
15#[cfg(test)]
16mod tests {
17    use super::paths::{extract_cd_target, sessions_dir};
18    use super::types::*;
19    use chrono::{Duration, Utc};
20
21    #[test]
22    fn load_latest_for_broad_root_returns_none_without_scanning() {
23        // The daemon boots with cwd "/" and the dispatcher passes that as the
24        // project root. Broad roots must bail out before walking the session
25        // store — stat-ing persisted roots under ~/Documents from the launchd
26        // daemon pops the macOS TCC prompt (#356).
27        assert!(SessionState::load_latest_for_project_root("/").is_none());
28        if let Some(home) = dirs::home_dir() {
29            let home = home.to_string_lossy().to_string();
30            assert!(SessionState::load_latest_for_project_root(&home).is_none());
31        }
32    }
33
34    #[test]
35    #[cfg(target_os = "macos")]
36    #[serial_test::serial]
37    fn normalize_session_skips_marker_probe_for_real_roots() {
38        // Both guards are needed: `#[serial]` and `test_env_lock` are separate
39        // mutexes, and the `LEAN_CTX_TCC_STANDALONE` this test relies on is read
40        // by every path normalization, including in tests that take only the lock.
41        let _env_lock = crate::core::data_dir::test_env_lock();
42        // A session whose project_root is a plausible real project must not be
43        // marker-probed at load time when the process is TCC-standalone: the
44        // probe itself would trip the privacy prompt (#356). The repair
45        // heuristic only ever fires for agent/temp roots.
46        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
47        let mut session = SessionState::new();
48        let docs_root = dirs::home_dir()
49            .unwrap_or_default()
50            .join("Documents/some-project")
51            .to_string_lossy()
52            .to_string();
53        session.project_root = Some(docs_root.clone());
54        session.shell_cwd = Some(docs_root.clone());
55        let normalized = super::heuristics::normalize_loaded_session(session);
56        // Root is not an agent/temp dir → kept as-is, no probe needed.
57        assert_eq!(normalized.project_root.as_deref(), Some(docs_root.as_str()));
58        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
59    }
60
61    /// #707: an explicit, jail-accepted `cwd` param must persist as the live
62    /// shell cwd — worktree switches arrive as `cwd` params, not `cd`
63    /// commands, and the divergence check in path resolution reads
64    /// `shell_cwd`. Relative or nonexistent paths are ignored.
65    #[test]
66    fn note_explicit_cwd_persists_absolute_dirs_only() {
67        let tmp = std::env::temp_dir().join(format!("lc_707_cwd_{}", std::process::id()));
68        std::fs::create_dir_all(&tmp).unwrap();
69
70        let mut session = SessionState::new();
71        session.note_explicit_cwd(&tmp.to_string_lossy());
72        let noted = session.shell_cwd.clone().expect("cwd should persist");
73        // Canonicalized (macOS /tmp → /private/tmp), so compare canonical forms.
74        assert_eq!(
75            noted,
76            crate::core::pathutil::safe_canonicalize_or_self(&tmp)
77                .to_string_lossy()
78                .to_string()
79        );
80
81        session.note_explicit_cwd("relative/dir");
82        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
83        session.note_explicit_cwd(&tmp.join("does-not-exist").to_string_lossy());
84        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
85
86        let _ = std::fs::remove_dir_all(&tmp);
87    }
88
89    /// #717: the 5-change batch alone left slowly-ticking sessions invisible
90    /// to the dashboard. First change on a fresh in-memory session flushes
91    /// immediately; afterwards the time-based trigger fires once a change is
92    /// older than SESSION_FLUSH_INTERVAL, and the batch threshold still wins.
93    #[test]
94    fn should_save_flushes_first_change_and_stale_changes() {
95        use super::state::SESSION_FLUSH_INTERVAL;
96        let mut session = SessionState::new();
97        assert!(!session.should_save(), "no changes → no save");
98
99        session.increment();
100        assert!(session.should_save(), "first change must flush immediately");
101
102        session.last_flush = Some(std::time::Instant::now());
103        assert!(
104            !session.should_save(),
105            "inside window + below batch → defer"
106        );
107
108        for _ in 0..4 {
109            session.increment();
110        }
111        assert!(session.should_save(), "batch threshold still applies");
112
113        session.stats.unsaved_changes = 1;
114        if let Some(old) = std::time::Instant::now().checked_sub(SESSION_FLUSH_INTERVAL) {
115            session.last_flush = Some(old);
116            assert!(session.should_save(), "stale change must trigger flush");
117        }
118    }
119
120    #[test]
121    fn delete_session_removes_file_snapshot_and_latest_pointer() {
122        let _data = crate::core::data_dir::isolated_data_dir();
123        let mut session = SessionState::new();
124        session.id = "delete-me".to_string();
125        session.save().unwrap();
126
127        let dir = sessions_dir().unwrap();
128        let path = dir.join("delete-me.json");
129        let snapshot = dir.join("delete-me_snapshot.txt");
130        let latest = dir.join("latest.json");
131        std::fs::write(&snapshot, "snapshot").unwrap();
132        assert!(path.exists());
133        assert!(snapshot.exists());
134        assert!(latest.exists());
135
136        assert!(SessionState::delete_session("delete-me").unwrap());
137
138        assert!(!path.exists());
139        assert!(!snapshot.exists());
140        assert!(!latest.exists());
141        assert!(SessionState::list_sessions().is_empty());
142    }
143
144    #[test]
145    fn delete_latest_session_repoints_latest_to_newest_remaining() {
146        let _data = crate::core::data_dir::isolated_data_dir();
147        let mut older = SessionState::new();
148        older.id = "older".to_string();
149        older.updated_at = Utc::now() - Duration::days(1);
150        older.save().unwrap();
151
152        let mut newer = SessionState::new();
153        newer.id = "newer".to_string();
154        newer.updated_at = Utc::now();
155        newer.save().unwrap();
156        assert_eq!(
157            SessionState::load_global_latest_pointer().unwrap().id,
158            "newer"
159        );
160
161        assert!(SessionState::delete_session("newer").unwrap());
162
163        let latest = SessionState::load_global_latest_pointer().unwrap();
164        assert_eq!(latest.id, "older");
165        assert_eq!(SessionState::list_sessions().len(), 1);
166    }
167
168    #[test]
169    fn delete_session_rejects_path_traversal_id() {
170        let data = crate::core::data_dir::isolated_data_dir();
171        let outside = data.path().join("outside.json");
172        std::fs::write(&outside, "{}").unwrap();
173
174        let err = SessionState::delete_session("../outside").unwrap_err();
175
176        assert_eq!(err, "invalid session id");
177        assert!(outside.exists());
178    }
179
180    #[test]
181    fn extract_cd_absolute_path() {
182        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
183        assert_eq!(result, Some("/usr/local/bin".to_string()));
184    }
185
186    #[test]
187    fn extract_cd_relative_path() {
188        let result = extract_cd_target("cd subdir", "/home/user");
189        assert_eq!(result, Some("/home/user/subdir".to_string()));
190    }
191
192    #[test]
193    fn extract_cd_with_chained_command() {
194        let result = extract_cd_target("cd /tmp && ls", "/home/user");
195        assert_eq!(result, Some("/tmp".to_string()));
196    }
197
198    #[test]
199    fn extract_cd_with_semicolon() {
200        let result = extract_cd_target("cd /tmp; ls", "/home/user");
201        assert_eq!(result, Some("/tmp".to_string()));
202    }
203
204    #[test]
205    fn extract_cd_parent_dir() {
206        let result = extract_cd_target("cd ..", "/home/user/project");
207        assert_eq!(result, Some("/home/user/project/..".to_string()));
208    }
209
210    #[test]
211    fn extract_cd_no_cd_returns_none() {
212        let result = extract_cd_target("ls -la", "/home/user");
213        assert!(result.is_none());
214    }
215
216    #[test]
217    fn extract_cd_bare_cd_goes_home() {
218        let result = extract_cd_target("cd", "/home/user");
219        assert!(result.is_some());
220    }
221
222    #[test]
223    fn effective_cwd_explicit_takes_priority() {
224        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
225        let sub = tmp.join("sub");
226        let _ = std::fs::create_dir_all(&sub);
227        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
228            .to_string_lossy()
229            .to_string();
230        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
231            .to_string_lossy()
232            .to_string();
233
234        let mut session = SessionState::new();
235        session.project_root = Some(root_canon);
236        let result = session.effective_cwd(Some(&sub_canon));
237        assert_eq!(result, sub_canon);
238        let _ = std::fs::remove_dir_all(&tmp);
239    }
240
241    #[cfg(not(feature = "no-jail"))]
242    #[test]
243    fn effective_cwd_explicit_outside_root_is_jailed() {
244        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
245        let _ = std::fs::create_dir_all(&tmp);
246        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
247            .to_string_lossy()
248            .to_string();
249
250        let mut session = SessionState::new();
251        session.project_root = Some(root_canon.clone());
252        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
253        assert_eq!(result, root_canon);
254        let _ = std::fs::remove_dir_all(&tmp);
255    }
256
257    /// The checked variant must report *why* a jailed cwd was rejected, so
258    /// `ctx_shell` can surface it instead of silently swapping in the root (#629).
259    #[cfg(not(feature = "no-jail"))]
260    #[test]
261    fn effective_cwd_checked_reports_jail_rejection_reason() {
262        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
263        let _ = std::fs::create_dir_all(&tmp);
264        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
265            .to_string_lossy()
266            .to_string();
267
268        let mut session = SessionState::new();
269        session.project_root = Some(root_canon.clone());
270
271        // Rejected: falls back to the root AND surfaces a non-empty reason.
272        let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
273        assert_eq!(path, root_canon);
274        let reason = reason.expect("a jailed cwd must report a rejection reason");
275        assert!(!reason.is_empty(), "the rejection reason must not be empty");
276
277        // Accepted (no explicit cwd): no reason, path is the project root.
278        let (accepted, none_reason) = session.effective_cwd_checked(None);
279        assert_eq!(accepted, root_canon);
280        assert!(
281            none_reason.is_none(),
282            "a non-jailed path must not report a reason"
283        );
284
285        let _ = std::fs::remove_dir_all(&tmp);
286    }
287
288    #[test]
289    fn effective_cwd_shell_cwd_second_priority() {
290        let mut session = SessionState::new();
291        session.project_root = Some("/project".to_string());
292        session.shell_cwd = Some("/project/src".to_string());
293        assert_eq!(session.effective_cwd(None), "/project/src");
294    }
295
296    #[test]
297    fn effective_cwd_project_root_third_priority() {
298        let mut session = SessionState::new();
299        session.project_root = Some("/project".to_string());
300        assert_eq!(session.effective_cwd(None), "/project");
301    }
302
303    #[test]
304    fn effective_cwd_dot_ignored() {
305        let mut session = SessionState::new();
306        session.project_root = Some("/project".to_string());
307        assert_eq!(session.effective_cwd(Some(".")), "/project");
308    }
309
310    #[test]
311    fn compaction_snapshot_includes_compression_config_when_enabled() {
312        let mut session = SessionState::new();
313        session.compression_level = "standard".to_string();
314        session.terse_mode = true;
315        session.set_task("x", None);
316        let snapshot = session.build_compaction_snapshot();
317        assert!(snapshot.contains("<config compression=\"standard\" />"));
318    }
319
320    #[test]
321    fn resume_block_prefixes_compression_hint_when_enabled() {
322        let mut session = SessionState::new();
323        session.compression_level = "lite".to_string();
324        session.terse_mode = true;
325        let block = session.build_resume_block();
326        assert!(block.contains("[COMPRESSION: lite]"));
327    }
328
329    #[test]
330    fn compaction_snapshot_includes_task() {
331        let mut session = SessionState::new();
332        session.set_task("fix auth bug", None);
333        let snapshot = session.build_compaction_snapshot();
334        assert!(snapshot.contains("<task>fix auth bug</task>"));
335        assert!(snapshot.contains("<session_snapshot>"));
336        assert!(snapshot.contains("</session_snapshot>"));
337    }
338
339    #[test]
340    fn compaction_snapshot_includes_files() {
341        let mut session = SessionState::new();
342        session.touch_file("src/auth.rs", None, "full", 500);
343        session.files_touched[0].modified = true;
344        session.touch_file("src/main.rs", None, "map", 100);
345        let snapshot = session.build_compaction_snapshot();
346        assert!(snapshot.contains("auth.rs"));
347        assert!(snapshot.contains("<files>"));
348    }
349
350    #[test]
351    fn compaction_snapshot_includes_decisions() {
352        let mut session = SessionState::new();
353        session.add_decision("Use JWT RS256", None);
354        let snapshot = session.build_compaction_snapshot();
355        assert!(snapshot.contains("JWT RS256"));
356        assert!(snapshot.contains("<decisions>"));
357    }
358
359    #[test]
360    fn compaction_snapshot_respects_size_limit() {
361        let mut session = SessionState::new();
362        session.set_task("a]task", None);
363        for i in 0..100 {
364            session.add_finding(
365                Some(&format!("file{i}.rs")),
366                Some(i),
367                &format!("Finding number {i} with some detail text here"),
368            );
369        }
370        let snapshot = session.build_compaction_snapshot();
371        assert!(snapshot.len() <= 2200);
372    }
373
374    #[test]
375    fn compaction_snapshot_includes_stats() {
376        let mut session = SessionState::new();
377        session.stats.total_tool_calls = 42;
378        session.stats.total_tokens_saved = 10000;
379        let snapshot = session.build_compaction_snapshot();
380        assert!(snapshot.contains("calls=42"));
381        assert!(snapshot.contains("saved=10000"));
382    }
383}