Skip to main content

lean_ctx/core/session/
mod.rs

1pub mod admission;
2pub mod caller;
3mod compaction;
4mod finding_sources;
5mod heuristics;
6pub mod housekeeping;
7mod journal;
8mod paths;
9mod persistence;
10pub mod playbook;
11mod save_outcome;
12mod slot;
13mod state;
14pub(crate) use journal::AttachSessionJournalV1;
15#[cfg(any(test, feature = "test-support"))]
16/// `TaskInfo.intent` of a task inferred from the working tree (tests).
17pub const GIT_TASK_INTENT: &str = state::GIT_TASK_INTENT;
18#[cfg(any(test, feature = "test-support"))]
19/// `TaskInfo.intent` of a task inferred from touched files (tests).
20pub const INFERRED_TASK_INTENT: &str = state::INFERRED_TASK_INTENT;
21pub use state::{extract_session_facts, is_auto_task_intent};
22mod types;
23pub(crate) use paths::{EngineSessionId, parse_engine_session_id};
24
25pub use playbook::{DeltaOutcome, EntryKind, Playbook, PlaybookEntry};
26pub use slot::{SessionSlot, SharedSession};
27pub use types::{
28    Decision, EvidenceKind, EvidenceRecord, FileTouched, Finding, LiveZoneSessionState,
29    PreparedSave, ProgressEntry, SaveError, SessionDayCounts, SessionState, SessionStats,
30    SessionSummary, TaskInfo, TestSnapshot,
31};
32
33#[cfg(test)]
34mod tests {
35    use super::paths::{extract_cd_target, sessions_dir};
36    use super::types::*;
37    use chrono::{Duration, Utc};
38
39    /// Bypass path "memory": findings and decisions the agent records are
40    /// recalled into later context, so a credential never enters them.
41    #[test]
42    fn session_memory_never_stores_a_credential() {
43        let _isolation = crate::core::data_dir::isolated_data_dir();
44        let credential = concat!("AK", "IAQ3EGRZ7MEMORYKEY");
45        let mut session = SessionState::new();
46        session.add_finding(Some("deploy.env"), Some(3), &format!("key is {credential}"));
47        session.add_decision(
48            &format!("rotate {credential}"),
49            Some(&format!("it leaked as {credential}")),
50        );
51        let stored = serde_json::to_string(&session).expect("serialize session");
52        assert!(
53            !stored.contains(credential),
54            "session memory kept the credential"
55        );
56        assert!(stored.contains("key is"), "the finding itself is kept");
57    }
58
59    /// `wakeup_manifest` is retired with LITM calibration: a session file
60    /// written before still loads, and its next save no longer carries it.
61    #[test]
62    fn a_session_with_the_retired_wakeup_manifest_still_loads() {
63        let _isolation = crate::core::data_dir::isolated_data_dir();
64        let mut stored = serde_json::to_value(SessionState::new()).expect("serialize session");
65        stored["wakeup_manifest"] = serde_json::json!([
66            { "key": "fact:a", "position": "begin", "profile": "claude", "missed": true }
67        ]);
68        let loaded: SessionState = serde_json::from_value(stored).expect("old session loads");
69        let saved = serde_json::to_value(&loaded).expect("serialize session");
70        assert!(saved.get("wakeup_manifest").is_none());
71    }
72
73    /// A git-derived task names the uncommitted files; it was a `--stat`
74    /// summary ("Modified: 2 files changed, 19 insertions(+) … in ") whose
75    /// words then steered task-filtered reads.
76    #[test]
77    fn git_task_names_uncommitted_files_not_diff_counters() {
78        use super::state::describe_uncommitted_files;
79        assert_eq!(describe_uncommitted_files(["", "  "]), None);
80        assert_eq!(
81            describe_uncommitted_files([".gitignore", "AGENTS.md"]).as_deref(),
82            Some("Uncommitted changes: .gitignore, AGENTS.md")
83        );
84        assert_eq!(
85            describe_uncommitted_files([
86                "rust/src/core/entropy.rs",
87                "rust/src/core/call_graph.rs",
88                "rust/src/core/quality.rs",
89                "README.md",
90                "docs/a.md",
91            ])
92            .as_deref(),
93            Some("Uncommitted changes: entropy.rs, call_graph.rs, quality.rs (+2 more)")
94        );
95    }
96
97    #[test]
98    fn load_latest_for_broad_root_returns_none_without_scanning() {
99        let _home = crate::core::data_dir::test_env_lock();
100        // The daemon boots with cwd "/" and the dispatcher passes that as the
101        // project root. Broad roots must bail out before walking the session
102        // store — stat-ing persisted roots under ~/Documents from the launchd
103        // daemon pops the macOS TCC prompt (#356).
104        assert!(SessionState::load_latest_for_project_root("/").is_none());
105        if let Some(home) = dirs::home_dir() {
106            let home = home.to_string_lossy().to_string();
107            assert!(SessionState::load_latest_for_project_root(&home).is_none());
108        }
109    }
110
111    #[test]
112    #[cfg(target_os = "macos")]
113    #[serial_test::serial]
114    fn normalize_session_skips_marker_probe_for_real_roots() {
115        // Both guards are needed: `#[serial]` and `test_env_lock` are separate
116        // mutexes, and the `LEAN_CTX_TCC_STANDALONE` this test relies on is read
117        // by every path normalization, including in tests that take only the lock.
118        let _env_lock = crate::core::data_dir::test_env_lock();
119        // A session whose project_root is a plausible real project must not be
120        // marker-probed at load time when the process is TCC-standalone: the
121        // probe itself would trip the privacy prompt (#356). The repair
122        // heuristic only ever fires for agent/temp roots.
123        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
124        let mut session = SessionState::new();
125        let docs_root = dirs::home_dir()
126            .unwrap_or_default()
127            .join("Documents/some-project")
128            .to_string_lossy()
129            .to_string();
130        session.project_root = Some(docs_root.clone());
131        session.shell_cwd = Some(docs_root.clone());
132        let normalized = super::heuristics::normalize_loaded_session(session);
133        // Root is not an agent/temp dir → kept as-is, no probe needed.
134        assert_eq!(normalized.project_root.as_deref(), Some(docs_root.as_str()));
135        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
136    }
137
138    /// #707: an explicit, jail-accepted `cwd` param must persist as the live
139    /// shell cwd — worktree switches arrive as `cwd` params, not `cd`
140    /// commands, and the divergence check in path resolution reads
141    /// `shell_cwd`. Relative or nonexistent paths are ignored.
142    #[test]
143    fn note_explicit_cwd_persists_absolute_dirs_only() {
144        let tmp = std::env::temp_dir().join(format!("lc_707_cwd_{}", std::process::id()));
145        std::fs::create_dir_all(&tmp).unwrap();
146
147        let mut session = SessionState::new();
148        session.note_explicit_cwd(&tmp.to_string_lossy());
149        let noted = session.shell_cwd.clone().expect("cwd should persist");
150        // Canonicalized (macOS /tmp → /private/tmp), so compare canonical forms.
151        assert_eq!(
152            noted,
153            crate::core::pathutil::safe_canonicalize_or_self(&tmp)
154                .to_string_lossy()
155                .to_string()
156        );
157
158        session.note_explicit_cwd("relative/dir");
159        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
160        session.note_explicit_cwd(&tmp.join("does-not-exist").to_string_lossy());
161        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
162
163        let _ = std::fs::remove_dir_all(&tmp);
164    }
165
166    /// #717: the 5-change batch alone left slowly-ticking sessions invisible
167    /// to the dashboard. First change on a fresh in-memory session flushes
168    /// immediately; afterwards the time-based trigger fires once a change is
169    /// older than SESSION_FLUSH_INTERVAL, and the batch threshold still wins.
170    #[test]
171    fn should_save_flushes_first_change_and_stale_changes() {
172        use super::state::SESSION_FLUSH_INTERVAL;
173        let mut session = SessionState::new();
174        assert!(!session.should_save(), "no changes → no save");
175
176        session.increment();
177        assert!(session.should_save(), "first change must flush immediately");
178
179        session.last_flush = Some(std::time::Instant::now());
180        assert!(
181            !session.should_save(),
182            "inside window + below batch → defer"
183        );
184
185        for _ in 0..4 {
186            session.increment();
187        }
188        assert!(session.should_save(), "batch threshold still applies");
189
190        session.stats.unsaved_changes = 1;
191        if let Some(old) = std::time::Instant::now().checked_sub(SESSION_FLUSH_INTERVAL) {
192            session.last_flush = Some(old);
193            assert!(session.should_save(), "stale change must trigger flush");
194        }
195    }
196
197    #[test]
198    fn delete_session_removes_file_snapshot_and_latest_pointer() {
199        let _data = crate::core::data_dir::isolated_data_dir();
200        let mut session = SessionState::new();
201        session.id = "delete-me".to_string();
202        session.save().unwrap();
203
204        let dir = sessions_dir().unwrap();
205        let path = dir.join("delete-me.json");
206        let snapshot = dir.join("delete-me_snapshot.txt");
207        let latest = dir.join("latest.json");
208        std::fs::write(&snapshot, "snapshot").unwrap();
209        assert!(path.exists());
210        assert!(snapshot.exists());
211        assert!(latest.exists());
212
213        assert!(SessionState::delete_session("delete-me").unwrap());
214
215        assert!(!path.exists());
216        assert!(!snapshot.exists());
217        assert!(!latest.exists());
218        assert!(SessionState::list_sessions().is_empty());
219    }
220
221    #[test]
222    fn delete_latest_session_repoints_latest_to_newest_remaining() {
223        let _data = crate::core::data_dir::isolated_data_dir();
224        let mut older = SessionState::new();
225        older.id = "older".to_string();
226        older.updated_at = Utc::now() - Duration::days(1);
227        older.save().unwrap();
228
229        let mut newer = SessionState::new();
230        newer.id = "newer".to_string();
231        newer.updated_at = Utc::now();
232        newer.save().unwrap();
233        assert_eq!(
234            SessionState::load_global_latest_pointer().unwrap().id,
235            "newer"
236        );
237
238        assert!(SessionState::delete_session("newer").unwrap());
239
240        let latest = SessionState::load_global_latest_pointer().unwrap();
241        assert_eq!(latest.id, "older");
242        assert_eq!(SessionState::list_sessions().len(), 1);
243    }
244
245    #[test]
246    fn delete_session_rejects_path_traversal_id() {
247        let data = crate::core::data_dir::isolated_data_dir();
248        let outside = data.path().join("outside.json");
249        std::fs::write(&outside, "{}").unwrap();
250
251        let err = SessionState::delete_session("../outside").unwrap_err();
252
253        assert_eq!(err, "invalid session id");
254        assert!(outside.exists());
255    }
256
257    #[test]
258    fn extract_cd_absolute_path() {
259        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
260        assert_eq!(result, Some("/usr/local/bin".to_string()));
261    }
262
263    #[test]
264    fn extract_cd_relative_path() {
265        let result = extract_cd_target("cd subdir", "/home/user");
266        assert_eq!(result, Some("/home/user/subdir".to_string()));
267    }
268
269    #[test]
270    fn extract_cd_with_chained_command() {
271        let result = extract_cd_target("cd /tmp && ls", "/home/user");
272        assert_eq!(result, Some("/tmp".to_string()));
273    }
274
275    #[test]
276    fn extract_cd_with_semicolon() {
277        let result = extract_cd_target("cd /tmp; ls", "/home/user");
278        assert_eq!(result, Some("/tmp".to_string()));
279    }
280
281    #[test]
282    fn extract_cd_parent_dir() {
283        let result = extract_cd_target("cd ..", "/home/user/project");
284        assert_eq!(result, Some("/home/user/project/..".to_string()));
285    }
286
287    #[test]
288    fn extract_cd_no_cd_returns_none() {
289        let result = extract_cd_target("ls -la", "/home/user");
290        assert!(result.is_none());
291    }
292
293    #[test]
294    fn extract_cd_bare_cd_goes_home() {
295        let result = extract_cd_target("cd", "/home/user");
296        assert!(result.is_some());
297    }
298
299    #[test]
300    fn effective_cwd_explicit_takes_priority() {
301        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
302        let sub = tmp.join("sub");
303        let _ = std::fs::create_dir_all(&sub);
304        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
305            .to_string_lossy()
306            .to_string();
307        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
308            .to_string_lossy()
309            .to_string();
310
311        let mut session = SessionState::new();
312        session.project_root = Some(root_canon);
313        let result = session.effective_cwd(Some(&sub_canon));
314        assert_eq!(result, sub_canon);
315        let _ = std::fs::remove_dir_all(&tmp);
316    }
317
318    #[cfg(not(feature = "no-jail"))]
319    #[test]
320    fn effective_cwd_explicit_outside_root_is_jailed() {
321        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
322        let _ = std::fs::create_dir_all(&tmp);
323        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
324            .to_string_lossy()
325            .to_string();
326
327        let mut session = SessionState::new();
328        session.project_root = Some(root_canon.clone());
329        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
330        assert_eq!(result, root_canon);
331        let _ = std::fs::remove_dir_all(&tmp);
332    }
333
334    /// The checked variant must report *why* a jailed cwd was rejected, so
335    /// `ctx_shell` can surface it instead of silently swapping in the root (#629).
336    #[cfg(not(feature = "no-jail"))]
337    #[test]
338    fn effective_cwd_checked_reports_jail_rejection_reason() {
339        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
340        let _ = std::fs::create_dir_all(&tmp);
341        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
342            .to_string_lossy()
343            .to_string();
344
345        let mut session = SessionState::new();
346        session.project_root = Some(root_canon.clone());
347
348        // Rejected: falls back to the root AND surfaces a non-empty reason.
349        let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
350        assert_eq!(path, root_canon);
351        let reason = reason.expect("a jailed cwd must report a rejection reason");
352        assert!(!reason.is_empty(), "the rejection reason must not be empty");
353
354        // Accepted (no explicit cwd): no reason, path is the project root.
355        let (accepted, none_reason) = session.effective_cwd_checked(None);
356        assert_eq!(accepted, root_canon);
357        assert!(
358            none_reason.is_none(),
359            "a non-jailed path must not report a reason"
360        );
361
362        let _ = std::fs::remove_dir_all(&tmp);
363    }
364
365    #[test]
366    fn effective_cwd_shell_cwd_second_priority() {
367        let mut session = SessionState::new();
368        session.project_root = Some("/project".to_string());
369        session.shell_cwd = Some("/project/src".to_string());
370        assert_eq!(session.effective_cwd(None), "/project/src");
371    }
372
373    #[test]
374    fn effective_cwd_project_root_third_priority() {
375        let mut session = SessionState::new();
376        session.project_root = Some("/project".to_string());
377        assert_eq!(session.effective_cwd(None), "/project");
378    }
379
380    #[test]
381    fn effective_cwd_dot_ignored() {
382        let mut session = SessionState::new();
383        session.project_root = Some("/project".to_string());
384        assert_eq!(session.effective_cwd(Some(".")), "/project");
385    }
386
387    /// A command must never run in a directory the caller did not name. An
388    /// explicit `cwd` outside the project jail is an error, not the project
389    /// root; a stored shell cwd that is no longer inside the project is one
390    /// too. Accepted directories resolve as before.
391    #[cfg(not(feature = "no-jail"))]
392    #[test]
393    fn execution_cwd_refuses_instead_of_substituting_the_project_root() {
394        let base = tempfile::tempdir().unwrap();
395        let root = base.path().join("project");
396        let inside = root.join("src");
397        let outside = base.path().join("elsewhere");
398        for dir in [&inside, &outside] {
399            std::fs::create_dir_all(dir).unwrap();
400        }
401        let canon = |path: &std::path::Path| {
402            crate::core::pathutil::safe_canonicalize_or_self(path)
403                .to_string_lossy()
404                .to_string()
405        };
406        let (root, inside, outside) = (canon(&root), canon(&inside), canon(&outside));
407
408        let mut session = SessionState::new();
409        session.project_root = Some(root.clone());
410
411        let refused = session.execution_cwd(Some(&outside));
412        assert!(
413            refused.as_ref().is_err_and(|reason| !reason.is_empty()),
414            "an explicit cwd outside the project must fail: {refused:?}"
415        );
416        assert_eq!(session.execution_cwd(Some(&inside)), Ok(inside.clone()));
417        assert_eq!(session.execution_cwd(None), Ok(root.clone()));
418        assert_eq!(session.execution_cwd(Some(".")), Ok(root.clone()));
419
420        session.shell_cwd = Some(inside.clone());
421        assert_eq!(session.execution_cwd(None), Ok(inside));
422
423        session.shell_cwd = Some(outside.clone());
424        let stale = session.execution_cwd(None);
425        assert!(
426            stale.is_err(),
427            "a stored shell cwd outside the project must fail: {stale:?}"
428        );
429
430        // Without a project root there is no jail to hold a stored cwd to.
431        session.project_root = None;
432        assert_eq!(session.execution_cwd(None), Ok(outside));
433    }
434
435    #[test]
436    fn compaction_snapshot_includes_compression_config_when_enabled() {
437        let mut session = SessionState::new();
438        session.compression_level = "standard".to_string();
439        session.terse_mode = true;
440        session.set_task("x", None);
441        let snapshot = session.build_compaction_snapshot();
442        assert!(snapshot.contains("<config compression=\"standard\" />"));
443    }
444
445    #[test]
446    fn resume_block_prefixes_compression_hint_when_enabled() {
447        let mut session = SessionState::new();
448        session.compression_level = "lite".to_string();
449        session.terse_mode = true;
450        let block = session.build_resume_block();
451        assert!(block.contains("[COMPRESSION: lite]"));
452    }
453
454    #[test]
455    fn compaction_snapshot_includes_task() {
456        let mut session = SessionState::new();
457        session.set_task("fix auth bug", None);
458        let snapshot = session.build_compaction_snapshot();
459        assert!(snapshot.contains("<task>fix auth bug</task>"));
460        assert!(snapshot.contains("<session_snapshot>"));
461        assert!(snapshot.contains("</session_snapshot>"));
462    }
463
464    #[test]
465    fn compaction_snapshot_includes_files() {
466        let mut session = SessionState::new();
467        session.touch_file("src/auth.rs", None, "full", 500);
468        session.files_touched[0].modified = true;
469        session.touch_file("src/main.rs", None, "map", 100);
470        let snapshot = session.build_compaction_snapshot();
471        assert!(snapshot.contains("auth.rs"));
472        assert!(snapshot.contains("<files>"));
473    }
474
475    #[test]
476    fn compaction_snapshot_includes_decisions() {
477        let mut session = SessionState::new();
478        session.add_decision("Use JWT RS256", None);
479        let snapshot = session.build_compaction_snapshot();
480        assert!(snapshot.contains("JWT RS256"));
481        assert!(snapshot.contains("<decisions>"));
482    }
483
484    #[test]
485    fn compaction_snapshot_respects_size_limit() {
486        let mut session = SessionState::new();
487        session.set_task("a]task", None);
488        for i in 0..100 {
489            session.add_finding(
490                Some(&format!("file{i}.rs")),
491                Some(i),
492                &format!("Finding number {i} with some detail text here"),
493            );
494        }
495        let snapshot = session.build_compaction_snapshot();
496        assert!(snapshot.len() <= 2200);
497    }
498
499    #[test]
500    fn compaction_snapshot_includes_stats() {
501        let mut session = SessionState::new();
502        session.stats.total_tool_calls = 42;
503        session.stats.total_tokens_saved = 10000;
504        let snapshot = session.build_compaction_snapshot();
505        assert!(snapshot.contains("calls=42"));
506        assert!(snapshot.contains("saved=10000"));
507    }
508}