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        // The daemon boots with cwd "/" and the dispatcher passes that as the
100        // project root. Broad roots must bail out before walking the session
101        // store — stat-ing persisted roots under ~/Documents from the launchd
102        // daemon pops the macOS TCC prompt (#356).
103        assert!(SessionState::load_latest_for_project_root("/").is_none());
104        if let Some(home) = dirs::home_dir() {
105            let home = home.to_string_lossy().to_string();
106            assert!(SessionState::load_latest_for_project_root(&home).is_none());
107        }
108    }
109
110    #[test]
111    #[cfg(target_os = "macos")]
112    #[serial_test::serial]
113    fn normalize_session_skips_marker_probe_for_real_roots() {
114        // Both guards are needed: `#[serial]` and `test_env_lock` are separate
115        // mutexes, and the `LEAN_CTX_TCC_STANDALONE` this test relies on is read
116        // by every path normalization, including in tests that take only the lock.
117        let _env_lock = crate::core::data_dir::test_env_lock();
118        // A session whose project_root is a plausible real project must not be
119        // marker-probed at load time when the process is TCC-standalone: the
120        // probe itself would trip the privacy prompt (#356). The repair
121        // heuristic only ever fires for agent/temp roots.
122        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
123        let mut session = SessionState::new();
124        let docs_root = dirs::home_dir()
125            .unwrap_or_default()
126            .join("Documents/some-project")
127            .to_string_lossy()
128            .to_string();
129        session.project_root = Some(docs_root.clone());
130        session.shell_cwd = Some(docs_root.clone());
131        let normalized = super::heuristics::normalize_loaded_session(session);
132        // Root is not an agent/temp dir → kept as-is, no probe needed.
133        assert_eq!(normalized.project_root.as_deref(), Some(docs_root.as_str()));
134        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
135    }
136
137    /// #707: an explicit, jail-accepted `cwd` param must persist as the live
138    /// shell cwd — worktree switches arrive as `cwd` params, not `cd`
139    /// commands, and the divergence check in path resolution reads
140    /// `shell_cwd`. Relative or nonexistent paths are ignored.
141    #[test]
142    fn note_explicit_cwd_persists_absolute_dirs_only() {
143        let tmp = std::env::temp_dir().join(format!("lc_707_cwd_{}", std::process::id()));
144        std::fs::create_dir_all(&tmp).unwrap();
145
146        let mut session = SessionState::new();
147        session.note_explicit_cwd(&tmp.to_string_lossy());
148        let noted = session.shell_cwd.clone().expect("cwd should persist");
149        // Canonicalized (macOS /tmp → /private/tmp), so compare canonical forms.
150        assert_eq!(
151            noted,
152            crate::core::pathutil::safe_canonicalize_or_self(&tmp)
153                .to_string_lossy()
154                .to_string()
155        );
156
157        session.note_explicit_cwd("relative/dir");
158        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
159        session.note_explicit_cwd(&tmp.join("does-not-exist").to_string_lossy());
160        assert_eq!(session.shell_cwd.as_deref(), Some(noted.as_str()));
161
162        let _ = std::fs::remove_dir_all(&tmp);
163    }
164
165    /// #717: the 5-change batch alone left slowly-ticking sessions invisible
166    /// to the dashboard. First change on a fresh in-memory session flushes
167    /// immediately; afterwards the time-based trigger fires once a change is
168    /// older than SESSION_FLUSH_INTERVAL, and the batch threshold still wins.
169    #[test]
170    fn should_save_flushes_first_change_and_stale_changes() {
171        use super::state::SESSION_FLUSH_INTERVAL;
172        let mut session = SessionState::new();
173        assert!(!session.should_save(), "no changes → no save");
174
175        session.increment();
176        assert!(session.should_save(), "first change must flush immediately");
177
178        session.last_flush = Some(std::time::Instant::now());
179        assert!(
180            !session.should_save(),
181            "inside window + below batch → defer"
182        );
183
184        for _ in 0..4 {
185            session.increment();
186        }
187        assert!(session.should_save(), "batch threshold still applies");
188
189        session.stats.unsaved_changes = 1;
190        if let Some(old) = std::time::Instant::now().checked_sub(SESSION_FLUSH_INTERVAL) {
191            session.last_flush = Some(old);
192            assert!(session.should_save(), "stale change must trigger flush");
193        }
194    }
195
196    #[test]
197    fn delete_session_removes_file_snapshot_and_latest_pointer() {
198        let _data = crate::core::data_dir::isolated_data_dir();
199        let mut session = SessionState::new();
200        session.id = "delete-me".to_string();
201        session.save().unwrap();
202
203        let dir = sessions_dir().unwrap();
204        let path = dir.join("delete-me.json");
205        let snapshot = dir.join("delete-me_snapshot.txt");
206        let latest = dir.join("latest.json");
207        std::fs::write(&snapshot, "snapshot").unwrap();
208        assert!(path.exists());
209        assert!(snapshot.exists());
210        assert!(latest.exists());
211
212        assert!(SessionState::delete_session("delete-me").unwrap());
213
214        assert!(!path.exists());
215        assert!(!snapshot.exists());
216        assert!(!latest.exists());
217        assert!(SessionState::list_sessions().is_empty());
218    }
219
220    #[test]
221    fn delete_latest_session_repoints_latest_to_newest_remaining() {
222        let _data = crate::core::data_dir::isolated_data_dir();
223        let mut older = SessionState::new();
224        older.id = "older".to_string();
225        older.updated_at = Utc::now() - Duration::days(1);
226        older.save().unwrap();
227
228        let mut newer = SessionState::new();
229        newer.id = "newer".to_string();
230        newer.updated_at = Utc::now();
231        newer.save().unwrap();
232        assert_eq!(
233            SessionState::load_global_latest_pointer().unwrap().id,
234            "newer"
235        );
236
237        assert!(SessionState::delete_session("newer").unwrap());
238
239        let latest = SessionState::load_global_latest_pointer().unwrap();
240        assert_eq!(latest.id, "older");
241        assert_eq!(SessionState::list_sessions().len(), 1);
242    }
243
244    #[test]
245    fn delete_session_rejects_path_traversal_id() {
246        let data = crate::core::data_dir::isolated_data_dir();
247        let outside = data.path().join("outside.json");
248        std::fs::write(&outside, "{}").unwrap();
249
250        let err = SessionState::delete_session("../outside").unwrap_err();
251
252        assert_eq!(err, "invalid session id");
253        assert!(outside.exists());
254    }
255
256    #[test]
257    fn extract_cd_absolute_path() {
258        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
259        assert_eq!(result, Some("/usr/local/bin".to_string()));
260    }
261
262    #[test]
263    fn extract_cd_relative_path() {
264        let result = extract_cd_target("cd subdir", "/home/user");
265        assert_eq!(result, Some("/home/user/subdir".to_string()));
266    }
267
268    #[test]
269    fn extract_cd_with_chained_command() {
270        let result = extract_cd_target("cd /tmp && ls", "/home/user");
271        assert_eq!(result, Some("/tmp".to_string()));
272    }
273
274    #[test]
275    fn extract_cd_with_semicolon() {
276        let result = extract_cd_target("cd /tmp; ls", "/home/user");
277        assert_eq!(result, Some("/tmp".to_string()));
278    }
279
280    #[test]
281    fn extract_cd_parent_dir() {
282        let result = extract_cd_target("cd ..", "/home/user/project");
283        assert_eq!(result, Some("/home/user/project/..".to_string()));
284    }
285
286    #[test]
287    fn extract_cd_no_cd_returns_none() {
288        let result = extract_cd_target("ls -la", "/home/user");
289        assert!(result.is_none());
290    }
291
292    #[test]
293    fn extract_cd_bare_cd_goes_home() {
294        let result = extract_cd_target("cd", "/home/user");
295        assert!(result.is_some());
296    }
297
298    #[test]
299    fn effective_cwd_explicit_takes_priority() {
300        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
301        let sub = tmp.join("sub");
302        let _ = std::fs::create_dir_all(&sub);
303        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
304            .to_string_lossy()
305            .to_string();
306        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
307            .to_string_lossy()
308            .to_string();
309
310        let mut session = SessionState::new();
311        session.project_root = Some(root_canon);
312        let result = session.effective_cwd(Some(&sub_canon));
313        assert_eq!(result, sub_canon);
314        let _ = std::fs::remove_dir_all(&tmp);
315    }
316
317    #[cfg(not(feature = "no-jail"))]
318    #[test]
319    fn effective_cwd_explicit_outside_root_is_jailed() {
320        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
321        let _ = std::fs::create_dir_all(&tmp);
322        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
323            .to_string_lossy()
324            .to_string();
325
326        let mut session = SessionState::new();
327        session.project_root = Some(root_canon.clone());
328        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
329        assert_eq!(result, root_canon);
330        let _ = std::fs::remove_dir_all(&tmp);
331    }
332
333    /// The checked variant must report *why* a jailed cwd was rejected, so
334    /// `ctx_shell` can surface it instead of silently swapping in the root (#629).
335    #[cfg(not(feature = "no-jail"))]
336    #[test]
337    fn effective_cwd_checked_reports_jail_rejection_reason() {
338        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-checked");
339        let _ = std::fs::create_dir_all(&tmp);
340        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
341            .to_string_lossy()
342            .to_string();
343
344        let mut session = SessionState::new();
345        session.project_root = Some(root_canon.clone());
346
347        // Rejected: falls back to the root AND surfaces a non-empty reason.
348        let (path, reason) = session.effective_cwd_checked(Some("/nonexistent-outside-path"));
349        assert_eq!(path, root_canon);
350        let reason = reason.expect("a jailed cwd must report a rejection reason");
351        assert!(!reason.is_empty(), "the rejection reason must not be empty");
352
353        // Accepted (no explicit cwd): no reason, path is the project root.
354        let (accepted, none_reason) = session.effective_cwd_checked(None);
355        assert_eq!(accepted, root_canon);
356        assert!(
357            none_reason.is_none(),
358            "a non-jailed path must not report a reason"
359        );
360
361        let _ = std::fs::remove_dir_all(&tmp);
362    }
363
364    #[test]
365    fn effective_cwd_shell_cwd_second_priority() {
366        let mut session = SessionState::new();
367        session.project_root = Some("/project".to_string());
368        session.shell_cwd = Some("/project/src".to_string());
369        assert_eq!(session.effective_cwd(None), "/project/src");
370    }
371
372    #[test]
373    fn effective_cwd_project_root_third_priority() {
374        let mut session = SessionState::new();
375        session.project_root = Some("/project".to_string());
376        assert_eq!(session.effective_cwd(None), "/project");
377    }
378
379    #[test]
380    fn effective_cwd_dot_ignored() {
381        let mut session = SessionState::new();
382        session.project_root = Some("/project".to_string());
383        assert_eq!(session.effective_cwd(Some(".")), "/project");
384    }
385
386    /// A command must never run in a directory the caller did not name. An
387    /// explicit `cwd` outside the project jail is an error, not the project
388    /// root; a stored shell cwd that is no longer inside the project is one
389    /// too. Accepted directories resolve as before.
390    #[cfg(not(feature = "no-jail"))]
391    #[test]
392    fn execution_cwd_refuses_instead_of_substituting_the_project_root() {
393        let base = tempfile::tempdir().unwrap();
394        let root = base.path().join("project");
395        let inside = root.join("src");
396        let outside = base.path().join("elsewhere");
397        for dir in [&inside, &outside] {
398            std::fs::create_dir_all(dir).unwrap();
399        }
400        let canon = |path: &std::path::Path| {
401            crate::core::pathutil::safe_canonicalize_or_self(path)
402                .to_string_lossy()
403                .to_string()
404        };
405        let (root, inside, outside) = (canon(&root), canon(&inside), canon(&outside));
406
407        let mut session = SessionState::new();
408        session.project_root = Some(root.clone());
409
410        let refused = session.execution_cwd(Some(&outside));
411        assert!(
412            refused.as_ref().is_err_and(|reason| !reason.is_empty()),
413            "an explicit cwd outside the project must fail: {refused:?}"
414        );
415        assert_eq!(session.execution_cwd(Some(&inside)), Ok(inside.clone()));
416        assert_eq!(session.execution_cwd(None), Ok(root.clone()));
417        assert_eq!(session.execution_cwd(Some(".")), Ok(root.clone()));
418
419        session.shell_cwd = Some(inside.clone());
420        assert_eq!(session.execution_cwd(None), Ok(inside));
421
422        session.shell_cwd = Some(outside.clone());
423        let stale = session.execution_cwd(None);
424        assert!(
425            stale.is_err(),
426            "a stored shell cwd outside the project must fail: {stale:?}"
427        );
428
429        // Without a project root there is no jail to hold a stored cwd to.
430        session.project_root = None;
431        assert_eq!(session.execution_cwd(None), Ok(outside));
432    }
433
434    #[test]
435    fn compaction_snapshot_includes_compression_config_when_enabled() {
436        let mut session = SessionState::new();
437        session.compression_level = "standard".to_string();
438        session.terse_mode = true;
439        session.set_task("x", None);
440        let snapshot = session.build_compaction_snapshot();
441        assert!(snapshot.contains("<config compression=\"standard\" />"));
442    }
443
444    #[test]
445    fn resume_block_prefixes_compression_hint_when_enabled() {
446        let mut session = SessionState::new();
447        session.compression_level = "lite".to_string();
448        session.terse_mode = true;
449        let block = session.build_resume_block();
450        assert!(block.contains("[COMPRESSION: lite]"));
451    }
452
453    #[test]
454    fn compaction_snapshot_includes_task() {
455        let mut session = SessionState::new();
456        session.set_task("fix auth bug", None);
457        let snapshot = session.build_compaction_snapshot();
458        assert!(snapshot.contains("<task>fix auth bug</task>"));
459        assert!(snapshot.contains("<session_snapshot>"));
460        assert!(snapshot.contains("</session_snapshot>"));
461    }
462
463    #[test]
464    fn compaction_snapshot_includes_files() {
465        let mut session = SessionState::new();
466        session.touch_file("src/auth.rs", None, "full", 500);
467        session.files_touched[0].modified = true;
468        session.touch_file("src/main.rs", None, "map", 100);
469        let snapshot = session.build_compaction_snapshot();
470        assert!(snapshot.contains("auth.rs"));
471        assert!(snapshot.contains("<files>"));
472    }
473
474    #[test]
475    fn compaction_snapshot_includes_decisions() {
476        let mut session = SessionState::new();
477        session.add_decision("Use JWT RS256", None);
478        let snapshot = session.build_compaction_snapshot();
479        assert!(snapshot.contains("JWT RS256"));
480        assert!(snapshot.contains("<decisions>"));
481    }
482
483    #[test]
484    fn compaction_snapshot_respects_size_limit() {
485        let mut session = SessionState::new();
486        session.set_task("a]task", None);
487        for i in 0..100 {
488            session.add_finding(
489                Some(&format!("file{i}.rs")),
490                Some(i),
491                &format!("Finding number {i} with some detail text here"),
492            );
493        }
494        let snapshot = session.build_compaction_snapshot();
495        assert!(snapshot.len() <= 2200);
496    }
497
498    #[test]
499    fn compaction_snapshot_includes_stats() {
500        let mut session = SessionState::new();
501        session.stats.total_tool_calls = 42;
502        session.stats.total_tokens_saved = 10000;
503        let snapshot = session.build_compaction_snapshot();
504        assert!(snapshot.contains("calls=42"));
505        assert!(snapshot.contains("saved=10000"));
506    }
507}