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