Skip to main content

lean_ctx/core/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::core::graph_context;
6use crate::core::intent_protocol::{IntentRecord, IntentSource};
7
8const MAX_FINDINGS: usize = 20;
9const MAX_DECISIONS: usize = 10;
10const MAX_FILES: usize = 50;
11const MAX_EVIDENCE: usize = 500;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14/// Persistent session state tracking task, findings, files, decisions, and stats.
15#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct SessionState {
17    pub id: String,
18    pub version: u32,
19    pub started_at: DateTime<Utc>,
20    pub updated_at: DateTime<Utc>,
21    pub project_root: Option<String>,
22    #[serde(default)]
23    pub shell_cwd: Option<String>,
24    pub task: Option<TaskInfo>,
25    pub findings: Vec<Finding>,
26    pub decisions: Vec<Decision>,
27    pub files_touched: Vec<FileTouched>,
28    pub test_results: Option<TestSnapshot>,
29    pub progress: Vec<ProgressEntry>,
30    pub next_steps: Vec<String>,
31    #[serde(default)]
32    pub evidence: Vec<EvidenceRecord>,
33    #[serde(default)]
34    pub intents: Vec<IntentRecord>,
35    #[serde(default)]
36    pub active_structured_intent: Option<crate::core::intent_engine::StructuredIntent>,
37    pub stats: SessionStats,
38    /// When true, resume / compaction prompts encourage concise model replies.
39    #[serde(default)]
40    pub terse_mode: bool,
41}
42
43/// Description of the current task being worked on, with optional progress tracking.
44#[derive(Serialize, Deserialize, Clone, Debug)]
45pub struct TaskInfo {
46    pub description: String,
47    pub intent: Option<String>,
48    pub progress_pct: Option<u8>,
49}
50
51/// A discovery or observation recorded during the session.
52#[derive(Serialize, Deserialize, Clone, Debug)]
53pub struct Finding {
54    pub file: Option<String>,
55    pub line: Option<u32>,
56    pub summary: String,
57    pub timestamp: DateTime<Utc>,
58}
59
60/// A design or implementation decision made during the session.
61#[derive(Serialize, Deserialize, Clone, Debug)]
62pub struct Decision {
63    pub summary: String,
64    pub rationale: Option<String>,
65    pub timestamp: DateTime<Utc>,
66}
67
68/// A file that was read or modified during the session.
69#[derive(Serialize, Deserialize, Clone, Debug)]
70pub struct FileTouched {
71    pub path: String,
72    pub file_ref: Option<String>,
73    pub read_count: u32,
74    pub modified: bool,
75    pub last_mode: String,
76    pub tokens: usize,
77    #[serde(default)]
78    pub stale: bool,
79    #[serde(default)]
80    pub context_item_id: Option<String>,
81}
82
83/// Snapshot of a test run with pass/fail counts.
84#[derive(Serialize, Deserialize, Clone, Debug)]
85pub struct TestSnapshot {
86    pub command: String,
87    pub passed: u32,
88    pub failed: u32,
89    pub total: u32,
90    pub timestamp: DateTime<Utc>,
91}
92
93/// A timestamped progress entry describing an action taken.
94#[derive(Serialize, Deserialize, Clone, Debug)]
95pub struct ProgressEntry {
96    pub action: String,
97    pub detail: Option<String>,
98    pub timestamp: DateTime<Utc>,
99}
100
101/// Source of an evidence record: automatic tool call or manual agent entry.
102#[derive(Serialize, Deserialize, Clone, Debug)]
103#[serde(rename_all = "snake_case")]
104pub enum EvidenceKind {
105    ToolCall,
106    Manual,
107}
108
109/// An auditable record of a tool invocation or manual observation.
110#[derive(Serialize, Deserialize, Clone, Debug)]
111pub struct EvidenceRecord {
112    pub kind: EvidenceKind,
113    pub key: String,
114    pub value: Option<String>,
115    pub tool: Option<String>,
116    pub input_md5: Option<String>,
117    pub output_md5: Option<String>,
118    pub agent_id: Option<String>,
119    pub client_name: Option<String>,
120    pub timestamp: DateTime<Utc>,
121}
122
123/// Aggregate counters for the session: tool calls, token savings, cache hits.
124#[derive(Serialize, Deserialize, Clone, Debug, Default)]
125#[serde(default)]
126pub struct SessionStats {
127    pub total_tool_calls: u32,
128    pub total_tokens_saved: u64,
129    pub total_tokens_input: u64,
130    pub cache_hits: u32,
131    pub files_read: u32,
132    pub commands_run: u32,
133    pub intents_inferred: u32,
134    pub intents_explicit: u32,
135    pub unsaved_changes: u32,
136}
137
138#[derive(Serialize, Deserialize, Clone, Debug)]
139struct LatestPointer {
140    id: String,
141}
142
143/// Pre-serialized session data ready for background disk I/O.
144/// Created by `SessionState::prepare_save()` while holding the write lock,
145/// then written via `write_to_disk()` after the lock is released.
146pub struct PreparedSave {
147    dir: PathBuf,
148    id: String,
149    json: String,
150    pointer_json: String,
151    compaction_snapshot: Option<String>,
152}
153
154impl PreparedSave {
155    /// Writes the pre-serialized session data, latest pointer, and compaction
156    /// snapshot to disk atomically.
157    pub fn write_to_disk(self) -> Result<(), String> {
158        if !self.dir.exists() {
159            std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
160        }
161        let path = self.dir.join(format!("{}.json", self.id));
162        let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
163        std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
164        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
165
166        let latest_path = self.dir.join("latest.json");
167        let latest_tmp = self.dir.join(".latest.json.tmp");
168        std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
169        std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
170
171        if let Some(snapshot) = self.compaction_snapshot {
172            let snap_path = self.dir.join(format!("{}_snapshot.txt", self.id));
173            let _ = std::fs::write(&snap_path, &snapshot);
174        }
175        Ok(())
176    }
177}
178
179impl Default for SessionState {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl SessionState {
186    /// Creates a new session with a unique ID and current timestamp.
187    pub fn new() -> Self {
188        let now = Utc::now();
189        Self {
190            id: generate_session_id(),
191            version: 0,
192            started_at: now,
193            updated_at: now,
194            project_root: None,
195            shell_cwd: None,
196            task: None,
197            findings: Vec::new(),
198            decisions: Vec::new(),
199            files_touched: Vec::new(),
200            test_results: None,
201            progress: Vec::new(),
202            next_steps: Vec::new(),
203            evidence: Vec::new(),
204            intents: Vec::new(),
205            active_structured_intent: None,
206            stats: SessionStats::default(),
207            terse_mode: crate::core::profiles::active_profile()
208                .compression
209                .terse_mode_effective(),
210        }
211    }
212
213    /// Bumps the version counter and marks the session as dirty.
214    pub fn increment(&mut self) {
215        self.version += 1;
216        self.updated_at = Utc::now();
217        self.stats.unsaved_changes += 1;
218    }
219
220    /// Returns `true` if enough changes have accumulated to warrant a disk save.
221    pub fn should_save(&self) -> bool {
222        self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
223    }
224
225    /// Sets the active task and infers a structured intent from the description.
226    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
227        self.task = Some(TaskInfo {
228            description: description.to_string(),
229            intent: intent.map(std::string::ToString::to_string),
230            progress_pct: None,
231        });
232
233        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
234        let si = if touched.is_empty() {
235            crate::core::intent_engine::StructuredIntent::from_query(description)
236        } else {
237            crate::core::intent_engine::StructuredIntent::from_query_with_session(
238                description,
239                &touched,
240            )
241        };
242        if si.confidence >= 0.7 {
243            self.active_structured_intent = Some(si);
244        }
245
246        self.increment();
247    }
248
249    /// Records a finding (discovery or observation) in the session log.
250    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
251        self.findings.push(Finding {
252            file: file.map(std::string::ToString::to_string),
253            line,
254            summary: summary.to_string(),
255            timestamp: Utc::now(),
256        });
257        while self.findings.len() > MAX_FINDINGS {
258            self.findings.remove(0);
259        }
260        self.increment();
261    }
262
263    /// Records a design or implementation decision with optional rationale.
264    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
265        self.decisions.push(Decision {
266            summary: summary.to_string(),
267            rationale: rationale.map(std::string::ToString::to_string),
268            timestamp: Utc::now(),
269        });
270        while self.decisions.len() > MAX_DECISIONS {
271            self.decisions.remove(0);
272        }
273        self.increment();
274    }
275
276    /// Records a file read/access in the session, incrementing its read count.
277    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
278        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
279            existing.read_count += 1;
280            existing.last_mode = mode.to_string();
281            existing.tokens = tokens;
282            if let Some(r) = file_ref {
283                existing.file_ref = Some(r.to_string());
284            }
285        } else {
286            let item_id = crate::core::context_field::ContextItemId::from_file(path);
287            self.files_touched.push(FileTouched {
288                path: path.to_string(),
289                file_ref: file_ref.map(std::string::ToString::to_string),
290                read_count: 1,
291                modified: false,
292                last_mode: mode.to_string(),
293                tokens,
294                stale: false,
295                context_item_id: Some(item_id.to_string()),
296            });
297            while self.files_touched.len() > MAX_FILES {
298                self.files_touched.remove(0);
299            }
300        }
301        self.stats.files_read += 1;
302        self.increment();
303    }
304
305    /// Marks a previously touched file as modified (written to).
306    pub fn mark_modified(&mut self, path: &str) {
307        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
308            existing.modified = true;
309        }
310        self.increment();
311    }
312
313    /// Increments the tool call counter and accumulates token savings.
314    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
315        self.stats.total_tool_calls += 1;
316        self.stats.total_tokens_saved += tokens_saved;
317        self.stats.total_tokens_input += tokens_input;
318    }
319
320    /// Records an inferred or explicit intent, coalescing consecutive duplicates.
321    pub fn record_intent(&mut self, mut intent: IntentRecord) {
322        if intent.occurrences == 0 {
323            intent.occurrences = 1;
324        }
325
326        if let Some(last) = self.intents.last_mut() {
327            if last.fingerprint() == intent.fingerprint() {
328                last.occurrences = last.occurrences.saturating_add(intent.occurrences);
329                last.timestamp = intent.timestamp;
330                match intent.source {
331                    IntentSource::Inferred => self.stats.intents_inferred += 1,
332                    IntentSource::Explicit => self.stats.intents_explicit += 1,
333                }
334                self.increment();
335                return;
336            }
337        }
338
339        match intent.source {
340            IntentSource::Inferred => self.stats.intents_inferred += 1,
341            IntentSource::Explicit => self.stats.intents_explicit += 1,
342        }
343
344        self.intents.push(intent);
345        while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
346            self.intents.remove(0);
347        }
348        self.increment();
349    }
350
351    /// Appends an auditable evidence record for a tool invocation.
352    pub fn record_tool_receipt(
353        &mut self,
354        tool: &str,
355        action: Option<&str>,
356        input_md5: &str,
357        output_md5: &str,
358        agent_id: Option<&str>,
359        client_name: Option<&str>,
360    ) {
361        let now = Utc::now();
362        let mut push = |key: String| {
363            self.evidence.push(EvidenceRecord {
364                kind: EvidenceKind::ToolCall,
365                key,
366                value: None,
367                tool: Some(tool.to_string()),
368                input_md5: Some(input_md5.to_string()),
369                output_md5: Some(output_md5.to_string()),
370                agent_id: agent_id.map(std::string::ToString::to_string),
371                client_name: client_name.map(std::string::ToString::to_string),
372                timestamp: now,
373            });
374        };
375
376        push(format!("tool:{tool}"));
377        if let Some(a) = action {
378            push(format!("tool:{tool}:{a}"));
379        }
380        while self.evidence.len() > MAX_EVIDENCE {
381            self.evidence.remove(0);
382        }
383        self.increment();
384    }
385
386    /// Appends a manual (non-tool) evidence record to the audit log.
387    pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
388        self.evidence.push(EvidenceRecord {
389            kind: EvidenceKind::Manual,
390            key: key.to_string(),
391            value: value.map(std::string::ToString::to_string),
392            tool: None,
393            input_md5: None,
394            output_md5: None,
395            agent_id: None,
396            client_name: None,
397            timestamp: Utc::now(),
398        });
399        while self.evidence.len() > MAX_EVIDENCE {
400            self.evidence.remove(0);
401        }
402        self.increment();
403    }
404
405    /// Returns `true` if an evidence record with the given key exists.
406    pub fn has_evidence_key(&self, key: &str) -> bool {
407        self.evidence.iter().any(|e| e.key == key)
408    }
409
410    /// Increments the session-level cache hit counter.
411    pub fn record_cache_hit(&mut self) {
412        self.stats.cache_hits += 1;
413    }
414
415    /// Increments the session-level command counter.
416    pub fn record_command(&mut self) {
417        self.stats.commands_run += 1;
418    }
419
420    /// Returns the effective working directory for shell commands.
421    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd.
422    /// Explicit CWD and stored shell_cwd are jail-checked against the project root
423    /// to prevent MCP clients from escaping the workspace.
424    pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
425        let root = self.project_root.as_deref().unwrap_or(".");
426        if let Some(cwd) = explicit_cwd {
427            if !cwd.is_empty() && cwd != "." {
428                return Self::jail_cwd(cwd, root);
429            }
430        }
431        if let Some(ref cwd) = self.shell_cwd {
432            return cwd.clone();
433        }
434        if let Some(ref r) = self.project_root {
435            return r.clone();
436        }
437        std::env::current_dir()
438            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
439    }
440
441    /// Verifies that `candidate` is within the project jail.
442    /// Falls back to `fallback_root` if the candidate escapes.
443    fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
444        let p = std::path::Path::new(candidate);
445        match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
446            Ok(jailed) => jailed.to_string_lossy().to_string(),
447            Err(_) => fallback_root.to_string(),
448        }
449    }
450
451    /// Updates shell_cwd by detecting `cd` in the command.
452    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
453    /// `cd ..`, and chained commands like `cd foo && ...`.
454    /// The new CWD is jail-checked against the project root.
455    pub fn update_shell_cwd(&mut self, command: &str) {
456        let base = self.effective_cwd(None);
457        if let Some(new_cwd) = extract_cd_target(command, &base) {
458            let path = std::path::Path::new(&new_cwd);
459            if path.exists() && path.is_dir() {
460                let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
461                    .to_string_lossy()
462                    .to_string();
463                let root = self.project_root.as_deref().unwrap_or(".");
464                if crate::core::pathjail::jail_path(
465                    std::path::Path::new(&canonical),
466                    std::path::Path::new(root),
467                )
468                .is_ok()
469                {
470                    self.shell_cwd = Some(canonical);
471                }
472            }
473        }
474    }
475
476    /// Formats the session state as a compact multi-line summary for agent context.
477    pub fn format_compact(&self) -> String {
478        let duration = self.updated_at - self.started_at;
479        let hours = duration.num_hours();
480        let mins = duration.num_minutes() % 60;
481        let duration_str = if hours > 0 {
482            format!("{hours}h {mins}m")
483        } else {
484            format!("{mins}m")
485        };
486
487        let mut lines = Vec::new();
488        lines.push(format!(
489            "SESSION v{} | {} | {} calls | {} tok saved",
490            self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
491        ));
492
493        if let Some(ref task) = self.task {
494            let pct = task
495                .progress_pct
496                .map_or(String::new(), |p| format!(" [{p}%]"));
497            lines.push(format!("Task: {}{pct}", task.description));
498        }
499
500        if let Some(ref root) = self.project_root {
501            lines.push(format!("Root: {}", shorten_path(root)));
502        }
503
504        if !self.findings.is_empty() {
505            let items: Vec<String> = self
506                .findings
507                .iter()
508                .rev()
509                .take(5)
510                .map(|f| {
511                    let loc = match (&f.file, f.line) {
512                        (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
513                        (Some(file), None) => shorten_path(file),
514                        _ => String::new(),
515                    };
516                    if loc.is_empty() {
517                        f.summary.clone()
518                    } else {
519                        format!("{loc} \u{2014} {}", f.summary)
520                    }
521                })
522                .collect();
523            lines.push(format!(
524                "Findings ({}): {}",
525                self.findings.len(),
526                items.join(" | ")
527            ));
528        }
529
530        if !self.decisions.is_empty() {
531            let items: Vec<&str> = self
532                .decisions
533                .iter()
534                .rev()
535                .take(3)
536                .map(|d| d.summary.as_str())
537                .collect();
538            lines.push(format!("Decisions: {}", items.join(" | ")));
539        }
540
541        if !self.files_touched.is_empty() {
542            let items: Vec<String> = self
543                .files_touched
544                .iter()
545                .rev()
546                .take(10)
547                .map(|f| {
548                    let status = if f.modified { "mod" } else { &f.last_mode };
549                    let r = f.file_ref.as_deref().unwrap_or("?");
550                    format!("[{r} {} {status}]", shorten_path(&f.path))
551                })
552                .collect();
553            lines.push(format!(
554                "Files ({}): {}",
555                self.files_touched.len(),
556                items.join(" ")
557            ));
558        }
559
560        if let Some(ref tests) = self.test_results {
561            lines.push(format!(
562                "Tests: {}/{} pass ({})",
563                tests.passed, tests.total, tests.command
564            ));
565        }
566
567        if !self.next_steps.is_empty() {
568            lines.push(format!("Next: {}", self.next_steps.join(" | ")));
569        }
570
571        lines.join("\n")
572    }
573
574    /// Builds a size-limited XML snapshot of session state for context compaction.
575    pub fn build_compaction_snapshot(&self) -> String {
576        const MAX_SNAPSHOT_BYTES: usize = 2048;
577
578        let mut sections: Vec<(u8, String)> = Vec::new();
579
580        if self.terse_mode {
581            sections.push((0, "<config terse=\"true\" />".to_string()));
582        }
583
584        if let Some(ref task) = self.task {
585            let pct = task
586                .progress_pct
587                .map_or(String::new(), |p| format!(" [{p}%]"));
588            sections.push((1, format!("<task>{}{pct}</task>", task.description)));
589        }
590
591        if !self.files_touched.is_empty() {
592            let modified: Vec<&str> = self
593                .files_touched
594                .iter()
595                .filter(|f| f.modified)
596                .map(|f| f.path.as_str())
597                .collect();
598            let read_only: Vec<&str> = self
599                .files_touched
600                .iter()
601                .filter(|f| !f.modified)
602                .take(10)
603                .map(|f| f.path.as_str())
604                .collect();
605            let mut files_section = String::new();
606            if !modified.is_empty() {
607                files_section.push_str(&format!("Modified: {}", modified.join(", ")));
608            }
609            if !read_only.is_empty() {
610                if !files_section.is_empty() {
611                    files_section.push_str(" | ");
612                }
613                files_section.push_str(&format!("Read: {}", read_only.join(", ")));
614            }
615            sections.push((1, format!("<files>{files_section}</files>")));
616        }
617
618        if !self.decisions.is_empty() {
619            let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
620            sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
621        }
622
623        if !self.findings.is_empty() {
624            let items: Vec<String> = self
625                .findings
626                .iter()
627                .rev()
628                .take(5)
629                .map(|f| f.summary.clone())
630                .collect();
631            sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
632        }
633
634        if !self.progress.is_empty() {
635            let items: Vec<String> = self
636                .progress
637                .iter()
638                .rev()
639                .take(5)
640                .map(|p| {
641                    let detail = p.detail.as_deref().unwrap_or("");
642                    if detail.is_empty() {
643                        p.action.clone()
644                    } else {
645                        format!("{}: {detail}", p.action)
646                    }
647                })
648                .collect();
649            sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
650        }
651
652        if let Some(ref tests) = self.test_results {
653            sections.push((
654                3,
655                format!(
656                    "<tests>{}/{} pass ({})</tests>",
657                    tests.passed, tests.total, tests.command
658                ),
659            ));
660        }
661
662        if !self.next_steps.is_empty() {
663            sections.push((
664                3,
665                format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
666            ));
667        }
668
669        sections.push((
670            4,
671            format!(
672                "<stats>calls={} saved={}tok</stats>",
673                self.stats.total_tool_calls, self.stats.total_tokens_saved
674            ),
675        ));
676
677        sections.sort_by_key(|(priority, _)| *priority);
678
679        const SNAPSHOT_HARD_CAP: usize = 2200;
680        const CLOSE_TAG: &str = "</session_snapshot>";
681        let open_len = "<session_snapshot>\n".len();
682        let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
683
684        let mut snapshot = String::from("<session_snapshot>\n");
685        for (_, section) in &sections {
686            if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
687                break;
688            }
689            snapshot.push_str(section);
690            snapshot.push('\n');
691        }
692
693        let used = snapshot.len().saturating_sub(open_len);
694        let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
695        if suffix_budget > 64 {
696            let suffix = self.build_compaction_structured_suffix(suffix_budget);
697            if !suffix.is_empty() {
698                snapshot.push_str(&suffix);
699                if !suffix.ends_with('\n') {
700                    snapshot.push('\n');
701                }
702            }
703        }
704
705        snapshot.push_str(CLOSE_TAG);
706        snapshot
707    }
708
709    /// Structured recovery hints (search/read/knowledge/graph) appended after legacy snapshot lines.
710    fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
711        if max_bytes <= 64 {
712            return String::new();
713        }
714
715        let mut recovery_queries: Vec<String> = Vec::new();
716        for ft in self.files_touched.iter().rev().take(12) {
717            let path_esc = escape_xml_attr(&ft.path);
718            let mode = if ft.last_mode.is_empty() {
719                "map".to_string()
720            } else {
721                escape_xml_attr(&ft.last_mode)
722            };
723            recovery_queries.push(format!(
724                r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
725            ));
726            let pattern = file_stem_search_pattern(&ft.path);
727            if !pattern.is_empty() {
728                let search_dir = parent_dir_slash(&ft.path);
729                let pat_esc = escape_xml_attr(&pattern);
730                let dir_esc = escape_xml_attr(&search_dir);
731                recovery_queries.push(format!(
732                    r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
733                ));
734            }
735        }
736
737        let mut parts: Vec<String> = Vec::new();
738        if !recovery_queries.is_empty() {
739            parts.push(format!(
740                "<recovery_queries>\n{}\n</recovery_queries>",
741                recovery_queries.join("\n")
742            ));
743        }
744
745        let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
746        if knowledge_ok {
747            if let Some(q) = self.knowledge_recall_query_stem() {
748                let q_esc = escape_xml_attr(&q);
749                parts.push(format!(
750                    "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
751                ));
752            }
753        }
754
755        if let Some(root) = self
756            .project_root
757            .as_deref()
758            .filter(|r| !r.trim().is_empty())
759        {
760            let root_trim = root.trim_end_matches('/');
761            let mut cluster_lines: Vec<String> = Vec::new();
762            for ft in self.files_touched.iter().rev().take(3) {
763                let primary_esc = escape_xml_attr(&ft.path);
764                let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
765                let related_csv =
766                    graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
767                        .map(|s| escape_xml_attr(&s))
768                        .unwrap_or_default();
769                if related_csv.is_empty() {
770                    continue;
771                }
772                cluster_lines.push(format!(
773                    r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
774                ));
775            }
776            if !cluster_lines.is_empty() {
777                parts.push(format!(
778                    "<graph_context>\n{}\n</graph_context>",
779                    cluster_lines.join("\n")
780                ));
781            }
782        }
783
784        Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
785    }
786
787    fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
788        let mut out = parts.join("\n");
789        while out.len() > max_bytes && !parts.is_empty() {
790            parts.pop();
791            out = parts.join("\n");
792        }
793        if out.len() <= max_bytes {
794            return out;
795        }
796        if let Some(idx) = parts
797            .iter()
798            .position(|p| p.starts_with("<recovery_queries>"))
799        {
800            let mut lines: Vec<String> = parts[idx]
801                .lines()
802                .filter(|l| l.starts_with("<query "))
803                .map(str::to_string)
804                .collect();
805            while !lines.is_empty() && out.len() > max_bytes {
806                if lines.len() == 1 {
807                    parts.remove(idx);
808                    out = parts.join("\n");
809                    break;
810                }
811                lines.truncate(lines.len().saturating_sub(2));
812                parts[idx] = format!(
813                    "<recovery_queries>\n{}\n</recovery_queries>",
814                    lines.join("\n")
815                );
816                out = parts.join("\n");
817            }
818        }
819        if out.len() > max_bytes {
820            return String::new();
821        }
822        out
823    }
824
825    fn knowledge_recall_query_stem(&self) -> Option<String> {
826        let mut bits: Vec<String> = Vec::new();
827        if let Some(ref t) = self.task {
828            bits.push(Self::task_keyword_stem(&t.description));
829        }
830        if bits.iter().all(std::string::String::is_empty) {
831            if let Some(f) = self.findings.last() {
832                bits.push(Self::task_keyword_stem(&f.summary));
833            } else if let Some(d) = self.decisions.last() {
834                bits.push(Self::task_keyword_stem(&d.summary));
835            }
836        }
837        let q = bits.join(" ").trim().to_string();
838        if q.is_empty() {
839            None
840        } else {
841            Some(q)
842        }
843    }
844
845    fn task_keyword_stem(text: &str) -> String {
846        const STOP: &[&str] = &[
847            "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
848            "be", "this", "that", "it", "as", "at", "by", "from",
849        ];
850        text.split_whitespace()
851            .filter_map(|w| {
852                let w = w.trim_matches(|c: char| !c.is_alphanumeric());
853                if w.len() < 3 {
854                    return None;
855                }
856                let lower = w.to_lowercase();
857                if STOP.contains(&lower.as_str()) {
858                    return None;
859                }
860                Some(w.to_string())
861            })
862            .take(8)
863            .collect::<Vec<_>>()
864            .join(" ")
865    }
866
867    /// Writes the compaction snapshot to disk and returns the snapshot string.
868    pub fn save_compaction_snapshot(&self) -> Result<String, String> {
869        let snapshot = self.build_compaction_snapshot();
870        let dir = sessions_dir().ok_or("cannot determine home directory")?;
871        if !dir.exists() {
872            std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
873        }
874        let path = dir.join(format!("{}_snapshot.txt", self.id));
875        std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
876        Ok(snapshot)
877    }
878
879    /// Loads a previously saved compaction snapshot by session ID.
880    pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
881        let dir = sessions_dir()?;
882        let path = dir.join(format!("{session_id}_snapshot.txt"));
883        std::fs::read_to_string(&path).ok()
884    }
885
886    /// Loads the most recently modified compaction snapshot from disk.
887    ///
888    /// When a project root can be derived from CWD, only snapshots whose
889    /// embedded session data matches the project root are considered. This
890    /// prevents cross-project snapshot leakage.
891    pub fn load_latest_snapshot() -> Option<String> {
892        let dir = sessions_dir()?;
893        let project_root = std::env::current_dir()
894            .ok()
895            .map(|p| p.to_string_lossy().to_string());
896
897        let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
898            .ok()?
899            .filter_map(std::result::Result::ok)
900            .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
901            .filter_map(|e| {
902                let meta = e.metadata().ok()?;
903                let modified = meta.modified().ok()?;
904
905                if let Some(ref root) = project_root {
906                    let content = std::fs::read_to_string(e.path()).ok()?;
907                    if !content.contains(root) {
908                        return None;
909                    }
910                }
911
912                Some((modified, e.path()))
913            })
914            .collect();
915
916        snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
917        snapshots
918            .first()
919            .and_then(|(_, path)| std::fs::read_to_string(path).ok())
920    }
921
922    /// Build a compact resume block for post-compaction injection.
923    /// Max ~500 tokens. Includes task, decisions, files, and archive references.
924    pub fn build_resume_block(&self) -> String {
925        let mut parts: Vec<String> = Vec::new();
926
927        if self.terse_mode {
928            parts.push(
929                "[TERSE MODE] Keep responses concise. Use bullet points, avoid filler. Focus on code and actions, not explanations."
930                    .to_string(),
931            );
932        }
933
934        if let Some(ref root) = self.project_root {
935            let short = root.rsplit('/').next().unwrap_or(root);
936            parts.push(format!("Project: {short}"));
937        }
938
939        if let Some(ref task) = self.task {
940            let pct = task
941                .progress_pct
942                .map_or(String::new(), |p| format!(" [{p}%]"));
943            parts.push(format!("Task: {}{pct}", task.description));
944        }
945
946        if !self.decisions.is_empty() {
947            let items: Vec<&str> = self
948                .decisions
949                .iter()
950                .rev()
951                .take(5)
952                .map(|d| d.summary.as_str())
953                .collect();
954            parts.push(format!("Decisions: {}", items.join("; ")));
955        }
956
957        if !self.files_touched.is_empty() {
958            let modified: Vec<&str> = self
959                .files_touched
960                .iter()
961                .filter(|f| f.modified)
962                .take(10)
963                .map(|f| f.path.as_str())
964                .collect();
965            if !modified.is_empty() {
966                parts.push(format!("Modified: {}", modified.join(", ")));
967            }
968        }
969
970        if !self.next_steps.is_empty() {
971            let steps: Vec<&str> = self
972                .next_steps
973                .iter()
974                .take(3)
975                .map(std::string::String::as_str)
976                .collect();
977            parts.push(format!("Next: {}", steps.join("; ")));
978        }
979
980        let archives = super::archive::list_entries(Some(&self.id));
981        if !archives.is_empty() {
982            let hints: Vec<String> = archives
983                .iter()
984                .take(5)
985                .map(|a| format!("{}({})", a.id, a.tool))
986                .collect();
987            parts.push(format!("Archives: {}", hints.join(", ")));
988        }
989
990        parts.push(format!(
991            "Stats: {} calls, {} tok saved",
992            self.stats.total_tool_calls, self.stats.total_tokens_saved
993        ));
994
995        format!(
996            "--- SESSION RESUME (post-compaction) ---\n{}\n---",
997            parts.join("\n")
998        )
999    }
1000
1001    /// Serializes and writes the session state to disk synchronously.
1002    pub fn save(&mut self) -> Result<(), String> {
1003        let prepared = self.prepare_save()?;
1004        match prepared.write_to_disk() {
1005            Ok(()) => Ok(()),
1006            Err(e) => {
1007                self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
1008                Err(e)
1009            }
1010        }
1011    }
1012
1013    /// Serialize session state while holding the lock (CPU-only), reset the
1014    /// unsaved counter, and return a `PreparedSave` whose I/O can be deferred
1015    /// to a background thread via `write_to_disk()`.
1016    pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
1017        let dir = sessions_dir().ok_or("cannot determine home directory")?;
1018        let compaction_snapshot = if self.stats.total_tool_calls > 0 {
1019            Some(self.build_compaction_snapshot())
1020        } else {
1021            None
1022        };
1023        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
1024        let pointer_json = serde_json::to_string(&LatestPointer {
1025            id: self.id.clone(),
1026        })
1027        .map_err(|e| e.to_string())?;
1028        self.stats.unsaved_changes = 0;
1029        Ok(PreparedSave {
1030            dir,
1031            id: self.id.clone(),
1032            json,
1033            pointer_json,
1034            compaction_snapshot,
1035        })
1036    }
1037
1038    /// Loads the most recent session from disk.
1039    ///
1040    /// Prefers the session matching the current working directory's project root.
1041    /// Falls back to the global `latest.json` pointer only if no project-scoped
1042    /// match is found. This prevents cross-project session leakage.
1043    pub fn load_latest() -> Option<Self> {
1044        if let Some(project_root) = std::env::current_dir()
1045            .ok()
1046            .map(|p| p.to_string_lossy().to_string())
1047        {
1048            if let Some(session) = Self::load_latest_for_project_root(&project_root) {
1049                return Some(session);
1050            }
1051        }
1052        let dir = sessions_dir()?;
1053        let latest_path = dir.join("latest.json");
1054        let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
1055        let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
1056        Self::load_by_id(&pointer.id)
1057    }
1058
1059    /// Loads the most recent session matching a specific project root.
1060    pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
1061        let dir = sessions_dir()?;
1062        let target_root =
1063            crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
1064        let mut latest_match: Option<Self> = None;
1065
1066        for entry in std::fs::read_dir(&dir).ok()?.flatten() {
1067            let path = entry.path();
1068            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1069                continue;
1070            }
1071            if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1072                continue;
1073            }
1074
1075            let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
1076                continue;
1077            };
1078            let Some(session) = Self::load_by_id(id) else {
1079                continue;
1080            };
1081
1082            if !session_matches_project_root(&session, &target_root) {
1083                continue;
1084            }
1085
1086            if latest_match
1087                .as_ref()
1088                .is_none_or(|existing| session.updated_at > existing.updated_at)
1089            {
1090                latest_match = Some(session);
1091            }
1092        }
1093
1094        latest_match
1095    }
1096
1097    /// Loads a specific session from disk by its unique ID.
1098    pub fn load_by_id(id: &str) -> Option<Self> {
1099        let dir = sessions_dir()?;
1100        let path = dir.join(format!("{id}.json"));
1101        let json = std::fs::read_to_string(&path).ok()?;
1102        let session: Self = serde_json::from_str(&json).ok()?;
1103        Some(normalize_loaded_session(session))
1104    }
1105
1106    /// Lists all saved sessions as summaries, sorted by most recently updated.
1107    pub fn list_sessions() -> Vec<SessionSummary> {
1108        let Some(dir) = sessions_dir() else {
1109            return Vec::new();
1110        };
1111
1112        let mut summaries = Vec::new();
1113        if let Ok(entries) = std::fs::read_dir(&dir) {
1114            for entry in entries.flatten() {
1115                let path = entry.path();
1116                if path.extension().and_then(|e| e.to_str()) != Some("json") {
1117                    continue;
1118                }
1119                if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1120                    continue;
1121                }
1122                if let Ok(json) = std::fs::read_to_string(&path) {
1123                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1124                        summaries.push(SessionSummary {
1125                            id: session.id,
1126                            started_at: session.started_at,
1127                            updated_at: session.updated_at,
1128                            version: session.version,
1129                            task: session.task.as_ref().map(|t| t.description.clone()),
1130                            tool_calls: session.stats.total_tool_calls,
1131                            tokens_saved: session.stats.total_tokens_saved,
1132                        });
1133                    }
1134                }
1135            }
1136        }
1137
1138        summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
1139        summaries
1140    }
1141
1142    /// Deletes sessions older than `max_age_days`, preserving the latest. Returns count removed.
1143    pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
1144        let Some(dir) = sessions_dir() else { return 0 };
1145
1146        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
1147        let latest = Self::load_latest().map(|s| s.id);
1148        let mut removed = 0u32;
1149
1150        if let Ok(entries) = std::fs::read_dir(&dir) {
1151            for entry in entries.flatten() {
1152                let path = entry.path();
1153                if path.extension().and_then(|e| e.to_str()) != Some("json") {
1154                    continue;
1155                }
1156                let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
1157                if filename == "latest" || filename.starts_with('.') {
1158                    continue;
1159                }
1160                if latest.as_deref() == Some(filename) {
1161                    continue;
1162                }
1163                if let Ok(json) = std::fs::read_to_string(&path) {
1164                    if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1165                        if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
1166                            removed += 1;
1167                        }
1168                    }
1169                }
1170            }
1171        }
1172
1173        removed
1174    }
1175}
1176
1177/// Lightweight summary of a session for listing purposes.
1178#[derive(Debug, Clone)]
1179pub struct SessionSummary {
1180    pub id: String,
1181    pub started_at: DateTime<Utc>,
1182    pub updated_at: DateTime<Utc>,
1183    pub version: u32,
1184    pub task: Option<String>,
1185    pub tool_calls: u32,
1186    pub tokens_saved: u64,
1187}
1188
1189fn escape_xml_attr(value: &str) -> String {
1190    value
1191        .replace('&', "&amp;")
1192        .replace('<', "&lt;")
1193        .replace('>', "&gt;")
1194        .replace('"', "&quot;")
1195}
1196
1197fn file_stem_search_pattern(path: &str) -> String {
1198    Path::new(path)
1199        .file_stem()
1200        .and_then(|s| s.to_str())
1201        .map(str::trim)
1202        .filter(|s| !s.is_empty() && s.chars().any(char::is_alphanumeric))
1203        .unwrap_or("")
1204        .to_string()
1205}
1206
1207fn parent_dir_slash(path: &str) -> String {
1208    Path::new(path)
1209        .parent()
1210        .and_then(|p| p.to_str())
1211        .map_or_else(
1212            || "./".to_string(),
1213            |p| {
1214                let norm = p.replace('\\', "/");
1215                let trimmed = norm.trim_end_matches('/');
1216                if trimmed.is_empty() {
1217                    "./".to_string()
1218                } else {
1219                    format!("{trimmed}/")
1220                }
1221            },
1222        )
1223}
1224
1225fn sessions_dir() -> Option<PathBuf> {
1226    crate::core::data_dir::lean_ctx_data_dir()
1227        .ok()
1228        .map(|d| d.join("sessions"))
1229}
1230
1231fn generate_session_id() -> String {
1232    static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1233    let now = Utc::now();
1234    let ts = now.format("%Y%m%d-%H%M%S").to_string();
1235    let nanos = now.timestamp_subsec_micros();
1236    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1237    format!("{ts}-{nanos:06}s{seq}")
1238}
1239
1240/// Extracts the `cd` target from a command string.
1241/// Handles patterns like `cd /foo`, `cd foo && bar`, `cd ../dir; cmd`, etc.
1242fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
1243    let first_cmd = command
1244        .split("&&")
1245        .next()
1246        .unwrap_or(command)
1247        .split(';')
1248        .next()
1249        .unwrap_or(command)
1250        .trim();
1251
1252    if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
1253        return None;
1254    }
1255
1256    let target = first_cmd.strip_prefix("cd")?.trim();
1257    if target.is_empty() || target == "~" {
1258        return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
1259    }
1260
1261    let target = target.trim_matches('"').trim_matches('\'');
1262    let path = std::path::Path::new(target);
1263
1264    if path.is_absolute() {
1265        Some(target.to_string())
1266    } else {
1267        let base = std::path::Path::new(base_cwd);
1268        let joined = base.join(target).to_string_lossy().to_string();
1269        Some(joined.replace('\\', "/"))
1270    }
1271}
1272
1273fn shorten_path(path: &str) -> String {
1274    let parts: Vec<&str> = path.split('/').collect();
1275    if parts.len() <= 2 {
1276        return path.to_string();
1277    }
1278    let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
1279    format!("…/{}/{}", last_two[1], last_two[0])
1280}
1281
1282fn normalize_loaded_session(mut session: SessionState) -> SessionState {
1283    if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
1284        session.project_root = None;
1285    }
1286    if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
1287        session.shell_cwd = None;
1288    }
1289
1290    // Heal stale project_root caused by agent/temp working directories.
1291    // If project_root doesn't look like a real project root but shell_cwd does, prefer shell_cwd.
1292    if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
1293        let root_p = std::path::Path::new(root);
1294        let cwd_p = std::path::Path::new(cwd);
1295        let root_looks_real = has_project_marker(root_p);
1296        let cwd_looks_real = has_project_marker(cwd_p);
1297
1298        if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
1299            session.project_root = Some(cwd.clone());
1300        }
1301    }
1302
1303    // Upgrade terse_mode from profile if session was created before the profile default.
1304    if !session.terse_mode {
1305        let profile_terse = crate::core::profiles::active_profile()
1306            .compression
1307            .terse_mode_effective();
1308        if profile_terse {
1309            session.terse_mode = true;
1310        }
1311    }
1312
1313    session
1314}
1315
1316fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
1317    if let Some(root) = session.project_root.as_deref() {
1318        let root_path =
1319            crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
1320        if root_path == target_root {
1321            return true;
1322        }
1323        if has_project_marker(&root_path) {
1324            return false;
1325        }
1326    }
1327
1328    if let Some(cwd) = session.shell_cwd.as_deref() {
1329        let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
1330        return cwd_path == target_root || cwd_path.starts_with(target_root);
1331    }
1332
1333    false
1334}
1335
1336fn has_project_marker(dir: &std::path::Path) -> bool {
1337    const MARKERS: &[&str] = &[
1338        ".git",
1339        ".lean-ctx.toml",
1340        "Cargo.toml",
1341        "package.json",
1342        "go.mod",
1343        "pyproject.toml",
1344        ".planning",
1345    ];
1346    MARKERS.iter().any(|m| dir.join(m).exists())
1347}
1348
1349fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1350    let s = dir.to_string_lossy();
1351    s.contains("/.claude")
1352        || s.contains("/.codex")
1353        || s.contains("/var/folders/")
1354        || s.contains("/tmp/")
1355        || s.contains("\\.claude")
1356        || s.contains("\\.codex")
1357        || s.contains("\\AppData\\Local\\Temp")
1358        || s.contains("\\Temp\\")
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363    use super::*;
1364
1365    #[test]
1366    fn extract_cd_absolute_path() {
1367        let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1368        assert_eq!(result, Some("/usr/local/bin".to_string()));
1369    }
1370
1371    #[test]
1372    fn extract_cd_relative_path() {
1373        let result = extract_cd_target("cd subdir", "/home/user");
1374        assert_eq!(result, Some("/home/user/subdir".to_string()));
1375    }
1376
1377    #[test]
1378    fn extract_cd_with_chained_command() {
1379        let result = extract_cd_target("cd /tmp && ls", "/home/user");
1380        assert_eq!(result, Some("/tmp".to_string()));
1381    }
1382
1383    #[test]
1384    fn extract_cd_with_semicolon() {
1385        let result = extract_cd_target("cd /tmp; ls", "/home/user");
1386        assert_eq!(result, Some("/tmp".to_string()));
1387    }
1388
1389    #[test]
1390    fn extract_cd_parent_dir() {
1391        let result = extract_cd_target("cd ..", "/home/user/project");
1392        assert_eq!(result, Some("/home/user/project/..".to_string()));
1393    }
1394
1395    #[test]
1396    fn extract_cd_no_cd_returns_none() {
1397        let result = extract_cd_target("ls -la", "/home/user");
1398        assert!(result.is_none());
1399    }
1400
1401    #[test]
1402    fn extract_cd_bare_cd_goes_home() {
1403        let result = extract_cd_target("cd", "/home/user");
1404        assert!(result.is_some());
1405    }
1406
1407    #[test]
1408    fn effective_cwd_explicit_takes_priority() {
1409        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
1410        let sub = tmp.join("sub");
1411        let _ = std::fs::create_dir_all(&sub);
1412        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1413            .to_string_lossy()
1414            .to_string();
1415        let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
1416            .to_string_lossy()
1417            .to_string();
1418
1419        let mut session = SessionState::new();
1420        session.project_root = Some(root_canon);
1421        let result = session.effective_cwd(Some(&sub_canon));
1422        assert_eq!(result, sub_canon);
1423        let _ = std::fs::remove_dir_all(&tmp);
1424    }
1425
1426    #[test]
1427    fn effective_cwd_explicit_outside_root_is_jailed() {
1428        let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
1429        let _ = std::fs::create_dir_all(&tmp);
1430        let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1431            .to_string_lossy()
1432            .to_string();
1433
1434        let mut session = SessionState::new();
1435        session.project_root = Some(root_canon.clone());
1436        let result = session.effective_cwd(Some("/nonexistent-outside-path"));
1437        assert_eq!(result, root_canon);
1438        let _ = std::fs::remove_dir_all(&tmp);
1439    }
1440
1441    #[test]
1442    fn effective_cwd_shell_cwd_second_priority() {
1443        let mut session = SessionState::new();
1444        session.project_root = Some("/project".to_string());
1445        session.shell_cwd = Some("/project/src".to_string());
1446        assert_eq!(session.effective_cwd(None), "/project/src");
1447    }
1448
1449    #[test]
1450    fn effective_cwd_project_root_third_priority() {
1451        let mut session = SessionState::new();
1452        session.project_root = Some("/project".to_string());
1453        assert_eq!(session.effective_cwd(None), "/project");
1454    }
1455
1456    #[test]
1457    fn effective_cwd_dot_ignored() {
1458        let mut session = SessionState::new();
1459        session.project_root = Some("/project".to_string());
1460        assert_eq!(session.effective_cwd(Some(".")), "/project");
1461    }
1462
1463    #[test]
1464    fn compaction_snapshot_includes_terse_config_when_enabled() {
1465        let mut session = SessionState::new();
1466        session.terse_mode = true;
1467        session.set_task("x", None);
1468        let snapshot = session.build_compaction_snapshot();
1469        assert!(snapshot.contains("<config terse=\"true\" />"));
1470    }
1471
1472    #[test]
1473    fn resume_block_prefixes_terse_instruction_when_enabled() {
1474        let mut session = SessionState::new();
1475        session.terse_mode = true;
1476        let block = session.build_resume_block();
1477        assert!(block.contains("[TERSE MODE]"));
1478    }
1479
1480    #[test]
1481    fn compaction_snapshot_includes_task() {
1482        let mut session = SessionState::new();
1483        session.set_task("fix auth bug", None);
1484        let snapshot = session.build_compaction_snapshot();
1485        assert!(snapshot.contains("<task>fix auth bug</task>"));
1486        assert!(snapshot.contains("<session_snapshot>"));
1487        assert!(snapshot.contains("</session_snapshot>"));
1488    }
1489
1490    #[test]
1491    fn compaction_snapshot_includes_files() {
1492        let mut session = SessionState::new();
1493        session.touch_file("src/auth.rs", None, "full", 500);
1494        session.files_touched[0].modified = true;
1495        session.touch_file("src/main.rs", None, "map", 100);
1496        let snapshot = session.build_compaction_snapshot();
1497        assert!(snapshot.contains("auth.rs"));
1498        assert!(snapshot.contains("<files>"));
1499    }
1500
1501    #[test]
1502    fn compaction_snapshot_includes_decisions() {
1503        let mut session = SessionState::new();
1504        session.add_decision("Use JWT RS256", None);
1505        let snapshot = session.build_compaction_snapshot();
1506        assert!(snapshot.contains("JWT RS256"));
1507        assert!(snapshot.contains("<decisions>"));
1508    }
1509
1510    #[test]
1511    fn compaction_snapshot_respects_size_limit() {
1512        let mut session = SessionState::new();
1513        session.set_task("a]task", None);
1514        for i in 0..100 {
1515            session.add_finding(
1516                Some(&format!("file{i}.rs")),
1517                Some(i),
1518                &format!("Finding number {i} with some detail text here"),
1519            );
1520        }
1521        let snapshot = session.build_compaction_snapshot();
1522        assert!(snapshot.len() <= 2200);
1523    }
1524
1525    #[test]
1526    fn compaction_snapshot_includes_stats() {
1527        let mut session = SessionState::new();
1528        session.stats.total_tool_calls = 42;
1529        session.stats.total_tokens_saved = 10000;
1530        let snapshot = session.build_compaction_snapshot();
1531        assert!(snapshot.contains("calls=42"));
1532        assert!(snapshot.contains("saved=10000"));
1533    }
1534}