Skip to main content

lean_ctx/core/
tool_lifecycle.rs

1//! Shared tool lifecycle — ensures CLI and MCP paths have identical side effects.
2//!
3//! The MCP server dispatcher handles session, ledger, heatmap, intent detection,
4//! and knowledge consolidation inline (via in-memory state). When the daemon is
5//! unavailable, CLI commands call functions here to achieve the same coverage by
6//! loading/saving state from disk.
7//!
8//! NOTE: When the daemon IS running, CLI routes through `daemon_client` which
9//! calls the MCP server — these functions are NOT called in that path.
10
11use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
12use crate::core::context_ledger::ContextLedger;
13use crate::core::heatmap;
14use crate::core::intent_engine::StructuredIntent;
15use crate::core::ocla::EfficiencyAnalyzer;
16use crate::core::session::SessionState;
17use crate::core::stats;
18
19/// How many recently-touched files form the "working set" a new read is
20/// associated with for traversal (co-access) edges (#289). Small, so the signal
21/// stays local to what the agent is actively juggling.
22const TRAVERSAL_WINDOW: usize = 6;
23
24/// Recent distinct file paths (excluding `current`), most-recent first, capped
25/// to the traversal window — the working set a new read co-occurs with.
26pub(crate) fn recent_working_set(session: &SessionState, current: &str) -> Vec<String> {
27    let mut out: Vec<String> = Vec::new();
28    for f in session.files_touched.iter().rev() {
29        if f.path == current || out.contains(&f.path) {
30            continue;
31        }
32        out.push(f.path.clone());
33        if out.len() >= TRAVERSAL_WINDOW {
34            break;
35        }
36    }
37    out
38}
39
40/// Whether `root` is a usable project root for repo-relative normalization.
41pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
42    root.filter(|r| !r.trim().is_empty() && *r != ".")
43}
44
45/// First 200 chars of `text` on a UTF-8 boundary — the exact excerpt bound the
46/// MCP dispatcher applies before handing content to the IR store
47/// (`server/call_tool.rs`), kept identical so CLI- and MCP-recorded IR items are
48/// byte-compatible. `ContextIrV1::record` redacts and further caps it.
49fn ir_excerpt(text: &str) -> &str {
50    const MAX: usize = 200;
51    if text.len() <= MAX {
52        return text;
53    }
54    let mut end = MAX;
55    while end > 0 && !text.is_char_boundary(end) {
56        end -= 1;
57    }
58    &text[..end]
59}
60
61/// Record a file-read operation with full Context OS side effects.
62///
63/// `duration` and `output_excerpt` feed the Context IR lineage (#566); the MCP
64/// dispatcher records both for every tool call but the shadow-mode `lean-ctx
65/// read` subprocess used to drop them, so IR/`ctx_proof` exports were blind to
66/// compressed shadow reads.
67pub fn record_file_read(
68    path: &str,
69    mode: &str,
70    original_tokens: usize,
71    output_tokens: usize,
72    is_cache_hit: bool,
73    duration: std::time::Duration,
74    output_excerpt: &str,
75) {
76    let saved = original_tokens.saturating_sub(output_tokens);
77    let tool_key = format!("cli_{mode}");
78
79    stats::record(&tool_key, original_tokens, output_tokens);
80    heatmap::record_file_access(path, original_tokens, saved);
81    // Verified ledger (#685): recorded explicitly now that the heatmap chokepoint
82    // no longer bundles it. This direct-CLI path (daemon off) only has o200k
83    // counts; the model-correct re-tokenization happens on the MCP read path,
84    // which holds the source text. For the default O200kBase model these are
85    // identical anyway.
86    crate::core::savings_ledger::record_read_event(original_tokens, saved, None, None);
87
88    // Project root the learning sinks below are scoped to. Defaults to "." (the
89    // MCP path's `project_root_snapshot` fallback) so a rootless read still
90    // trains a global model rather than being dropped.
91    let mut learning_root = String::from(".");
92
93    if let Some(mut session) = SessionState::load_latest() {
94        session.touch_file(path, None, mode, original_tokens);
95        if is_cache_hit {
96            session.record_cache_hit();
97            crate::core::telemetry::global_metrics().record_cache(true);
98        }
99
100        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
101            let touched: Vec<String> = session
102                .files_touched
103                .iter()
104                .map(|ft| ft.path.clone())
105                .collect();
106            let inferred = StructuredIntent::from_file_patterns(&touched);
107            if inferred.confidence >= 0.4 {
108                session.active_structured_intent = Some(inferred);
109            }
110        }
111
112        let project_root = session.project_root.clone();
113        if let Some(root) = usable_root(project_root.as_deref()) {
114            learning_root = root.to_string();
115        }
116        let calls = session.stats.total_tool_calls;
117
118        // Traversal edges: associate this read with the recent working set so the
119        // graph learns the files this task actually touches together (#289).
120        let working_set = recent_working_set(&session, path);
121
122        let _ = session.save();
123
124        if let Some(root) = usable_root(project_root.as_deref()) {
125            crate::core::cooccurrence::record_focus_access(root, path, &working_set);
126        }
127        maybe_consolidate(project_root.as_deref(), calls);
128    }
129
130    // Only real files belong in the context ledger (GL #512): directory
131    // overviews and synthetic paths would show up as "files" in the pressure
132    // table with eviction/pin semantics that make no sense for them.
133    if std::path::Path::new(path).is_file() {
134        let mut ledger = ContextLedger::load();
135        ledger.record(path, mode, original_tokens, output_tokens);
136        ledger.save();
137    }
138
139    // Learning sinks the MCP read path runs in a background thread but the CLI
140    // path historically skipped — the mode predictor never trained, the
141    // compression feedback loop stayed blind and dashboard anomaly signals were
142    // missing for every shadow-mode (`view`/`grep` → `lean-ctx read`) hook read
143    // (#550). Run inline: a single-shot CLI process must finish them before it
144    // flushes and exits, so the off-hot-path thread the daemon uses is moot here.
145    record_read_learning(
146        path,
147        mode,
148        original_tokens,
149        output_tokens,
150        is_cache_hit,
151        &learning_root,
152    );
153
154    // Context IR lineage (#566): the MCP dispatcher records provenance for every
155    // tool call (`server/call_tool.rs`), but the shadow-mode hook's single-shot
156    // `lean-ctx read` bypassed it. Disk-backed load→record→save persists the
157    // entry before the process exits. `mode` rides the IR `pattern` slot to match
158    // the MCP read path (which stores its `mode` arg there).
159    let mut ir = ContextIrV1::load();
160    ir.record(RecordIrInput {
161        kind: ContextIrSourceKindV1::Read,
162        tool: "ctx_read",
163        client_name: None,
164        agent_id: None,
165        path: Some(path),
166        command: None,
167        pattern: Some(mode),
168        input_tokens: original_tokens,
169        output_tokens,
170        duration,
171        content_excerpt: ir_excerpt(output_excerpt),
172    });
173    ir.save();
174
175    // OCLA CompressionProvider runtime projection: for aggressive-mode reads with
176    // positive savings, record the compression event through the canonical OCLA
177    // capability so the registry tracks real compression evidence.
178    if saved > 0 {
179        project_ocla_savings(path, original_tokens as u64, output_tokens as u64);
180    }
181    if mode == "aggressive" && saved > 0 {
182        project_ocla_compression(path, original_tokens as u64, output_tokens as u64);
183    }
184}
185
186/// Replicate the MCP read path's learning side effects (`registered/ctx_read.rs`
187/// background thread) for the standalone CLI path (#550): mode-predictor
188/// training, the compression feedback outcome and the per-call anomaly metric.
189/// All three are disk-backed and therefore work from a single-shot process; the
190/// in-memory-only detectors (loop/correction) and the bounce/adaptive signals
191/// that require routing through `ctx_read::handle` are tracked separately.
192fn record_read_learning(
193    path: &str,
194    resolved_mode: &str,
195    original_tokens: usize,
196    output_tokens: usize,
197    is_cache_hit: bool,
198    project_root: &str,
199) {
200    let task_completed = crate::core::bounce_tracker::global()
201        .lock()
202        .ok()
203        .and_then(|bt| bt.bounce_rate_for_extension(path))
204        .is_none_or(|rate| rate < 0.30);
205    let saved = original_tokens.saturating_sub(output_tokens);
206
207    // Route the realized read through the OCLA efficiency capability so the
208    // production CLI path records the same ETPAO semantics as the contract.
209    // The analyzer is local and deterministic; failure keeps the legacy ratio.
210    let ocla_density = ocla_read_density(
211        path,
212        resolved_mode,
213        original_tokens,
214        output_tokens,
215        task_completed,
216        project_root,
217    );
218    record_outcome(
219        path,
220        resolved_mode,
221        original_tokens,
222        saved,
223        task_completed,
224        project_root,
225    );
226
227    // Mode predictor: train auto-mode selection on the realized compression
228    // density, exactly as the MCP background thread does.
229    let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
230    let density = ocla_density.unwrap_or_else(|| {
231        if output_tokens > 0 {
232            original_tokens as f64 / output_tokens as f64
233        } else {
234            1.0
235        }
236    });
237    let outcome = crate::core::mode_predictor::ModeOutcome {
238        mode: resolved_mode.to_string(),
239        tokens_in: original_tokens,
240        tokens_out: output_tokens,
241        density: density.min(1.0),
242    };
243    let mut predictor = crate::core::mode_predictor::ModePredictor::new();
244    predictor.set_project_root(project_root);
245    predictor.record(sig, outcome);
246    predictor.save();
247
248    // Compression feedback: the per-language outcome the adaptive thresholds and
249    // bounce-aware tuning learn from. `total_turns`/`total_reads` are 1 — the
250    // accurate count for this single-shot invocation, not a placeholder.
251    let ext = std::path::Path::new(path)
252        .extension()
253        .and_then(|e| e.to_str())
254        .unwrap_or("")
255        .to_string();
256    let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(path);
257    let feedback_outcome = crate::core::feedback::CompressionOutcome {
258        session_id: format!("{}", std::process::id()),
259        language: ext,
260        entropy_threshold: thresholds.bpe_entropy,
261        jaccard_threshold: thresholds.jaccard,
262        total_turns: 1,
263        tokens_saved: saved as u64,
264        tokens_original: original_tokens as u64,
265        cache_hits: u32::from(is_cache_hit),
266        total_reads: 1,
267        // A compressed read only counts as task-completing when this extension
268        // is not in a high-bounce state (#593); unknown stays optimistic so the
269        // cold start matches the MCP path. 0.30 mirrors BOUNCE_RATE_THRESHOLD.
270        task_completed,
271        timestamp: chrono::Local::now().to_rfc3339(),
272    };
273    let mut store = crate::core::feedback::FeedbackStore::load();
274    store.project_root = Some(project_root.to_string());
275    store.record_outcome(feedback_outcome);
276
277    // Anomaly detector: the same per-call metric the MCP post-dispatch records.
278    // `save_debounced` writes on the first call of a fresh process (last-save
279    // marker starts at 0), so the single shadow read persists before exit.
280    crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
281    crate::core::anomaly::save_debounced();
282}
283
284fn record_outcome(
285    path: &str,
286    resolved_mode: &str,
287    original_tokens: usize,
288    saved: usize,
289    task_completed: bool,
290    project_root: &str,
291) {
292    let context = crate::core::ocla::OclaRequestContext {
293        request_id: format!("cli-read:{path}:{resolved_mode}"),
294        session_id: SessionState::load_latest()
295            .map_or_else(|| "cli-read".to_string(), |session| session.id),
296        agent_id: "lean-ctx".to_string(),
297        content_ref: format!("file:{path}"),
298        tenant_id: None,
299        trace_id: "tr-unit".into(),
300    };
301    let outcome = crate::core::ocla::Outcome {
302        context,
303        accepted: Some(task_completed),
304        quality_score_milli: (original_tokens > 0)
305            .then(|| ((saved as u64 * 1000) / original_tokens as u64).min(1000) as u16),
306        outcome_ref: Some(format!("read:{project_root}:{resolved_mode}")),
307    };
308    let _ = crate::core::ocla::OclaRegistry::global()
309        .outcome_tracker
310        .record_outcome(outcome);
311}
312
313/// Compute read density through the production OCLA efficiency capability.
314/// Returns `None` when no accepted outcome can produce an ETPAO value.
315fn ocla_read_density(
316    path: &str,
317    resolved_mode: &str,
318    original_tokens: usize,
319    output_tokens: usize,
320    task_completed: bool,
321    project_root: &str,
322) -> Option<f64> {
323    let analyzer = crate::core::ocla::OclaRegistry::global()
324        .efficiency_analyzer
325        .as_ref();
326    read_density_with_analyzer(
327        analyzer,
328        path,
329        resolved_mode,
330        original_tokens,
331        output_tokens,
332        task_completed,
333        project_root,
334    )
335}
336
337fn read_density_with_analyzer(
338    analyzer: &dyn EfficiencyAnalyzer,
339    path: &str,
340    resolved_mode: &str,
341    original_tokens: usize,
342    output_tokens: usize,
343    task_completed: bool,
344    project_root: &str,
345) -> Option<f64> {
346    analyzer
347        .analyze_efficiency(crate::core::ocla::EfficiencySample {
348            context: crate::core::ocla::OclaRequestContext {
349                request_id: format!("read:{path}:{resolved_mode}"),
350                session_id: project_root.to_string(),
351                agent_id: "lean-ctx".to_string(),
352                content_ref: path.to_string(),
353                tenant_id: None,
354                trace_id: "tr-unit".into(),
355            },
356            original_tokens: original_tokens as u64,
357            delivered_tokens: output_tokens as u64,
358            accepted: Some(task_completed),
359            cache_reads: 0,
360            cache_hits: 0,
361        })
362        .ok()
363        .and_then(|analysis| analysis.etpao_milli)
364        .map(|milli| milli as f64 / 1000.0)
365}
366
367/// Record a search/grep operation with full Context OS side effects.
368///
369/// `modeled_baseline` (native-tool estimate, GL #479 D1) feeds the estimated
370/// stats series; `observed_tokens` (raw measured match lines, no factor) feeds
371/// the verified ledger (GL #479 D2). `pattern`/`path`/`duration`/`output_excerpt`
372/// feed the Context IR lineage (#566).
373pub fn record_search(
374    modeled_baseline: usize,
375    observed_tokens: usize,
376    output_tokens: usize,
377    pattern: &str,
378    path: &str,
379    duration: std::time::Duration,
380    output_excerpt: &str,
381) {
382    stats::record("cli_grep", modeled_baseline, output_tokens);
383    crate::core::savings_ledger::record_tool_event(
384        "cli_grep",
385        observed_tokens,
386        output_tokens,
387        None,
388        None,
389    );
390
391    if let Some(mut session) = SessionState::load_latest() {
392        session.record_command();
393        let project_root = session.project_root.clone();
394        let calls = session.stats.total_tool_calls;
395        let _ = session.save();
396
397        maybe_consolidate(project_root.as_deref(), calls);
398    }
399
400    // Per-call anomaly metric, mirroring the MCP post-dispatch (#550). Missing it
401    // left dashboard signals blind to shadow-mode (`grep` → `lean-ctx grep`) hooks.
402    crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
403    crate::core::anomaly::save_debounced();
404
405    // Context IR lineage for shadow-mode `grep` → `lean-ctx grep` (#566). The
406    // raw matched-line estimate (`observed_tokens`) is the IR input so the stored
407    // compression ratio reads matches-in / sent-out.
408    let mut ir = ContextIrV1::load();
409    ir.record(RecordIrInput {
410        kind: ContextIrSourceKindV1::Search,
411        tool: "ctx_search",
412        client_name: None,
413        agent_id: None,
414        path: Some(path),
415        command: None,
416        pattern: Some(pattern),
417        input_tokens: observed_tokens,
418        output_tokens,
419        duration,
420        content_excerpt: ir_excerpt(output_excerpt),
421    });
422    ir.save();
423}
424
425/// Record a tree/ls operation with full Context OS side effects.
426pub fn record_tree(original_tokens: usize, output_tokens: usize) {
427    stats::record("cli_ls", original_tokens, output_tokens);
428
429    if let Some(mut session) = SessionState::load_latest() {
430        session.record_command();
431        let _ = session.save();
432    }
433}
434
435/// Record a shell command with full Context OS side effects.
436/// Always records in stats (even for track-only 0-token calls) so the dashboard
437/// command counter stays accurate. Adding 0 tokens does not inflate savings.
438pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
439    stats::record("cli_shell", original_tokens, output_tokens);
440    // Shell compression is *measured* (raw output vs sent output), so it belongs
441    // in the verified ledger too (GL #479 D2). Zero-saving calls are skipped.
442    crate::core::savings_ledger::record_tool_event(
443        "cli_shell",
444        original_tokens,
445        output_tokens,
446        None,
447        None,
448    );
449
450    if let Some(mut session) = SessionState::load_latest() {
451        session.record_command();
452        let project_root = session.project_root.clone();
453        let calls = session.stats.total_tool_calls;
454        let _ = session.save();
455
456        if original_tokens > 0 {
457            maybe_consolidate(project_root.as_deref(), calls);
458        }
459    }
460}
461
462/// Flush every buffered telemetry sink to disk.
463///
464/// The long-lived MCP daemon flushes these once at shutdown
465/// (`cli/dispatch/server.rs`). Single-shot CLI commands — and the shadow-mode
466/// hook subprocesses that spawn `lean-ctx read`/`grep` — exit immediately, so
467/// without this the buffered heatmap, mode-predictor, feedback and threshold
468/// writes are silently lost the moment the process ends: `lean-ctx heatmap`
469/// stays empty and `lean-ctx gain` reports nothing for compressed reads (#550).
470///
471/// Centralized so the daemon shutdown, the parent watchdog and every CLI tool
472/// command flush the *exact same* set — the historical per-arm copies had
473/// drifted (the `read` arm flushed only `stats`, the `-c` arm four sinks, the
474/// daemon nine), which is precisely how the gap went unnoticed.
475pub fn flush_all() {
476    stats::flush();
477    heatmap::flush();
478    crate::core::path_mode_memory::flush();
479    crate::core::grammar_usage::flush();
480    crate::core::auto_mode_resolver::flush_sources();
481    crate::core::edit_quality::flush();
482    crate::core::edit_metering::flush();
483    crate::core::mode_predictor::ModePredictor::flush();
484    crate::core::feedback::FeedbackStore::flush();
485    crate::core::threshold_learning::flush();
486    crate::core::litm_calibration::flush();
487}
488
489fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
490    let Some(root) = project_root else { return };
491    let autonomy = crate::core::autonomy::AutonomyState::new();
492    if crate::core::autonomy::should_auto_consolidate(&autonomy, calls) {
493        let root = root.to_string();
494        let _ = crate::core::consolidation_engine::consolidate_latest(
495            &root,
496            crate::core::consolidation_engine::ConsolidationBudgets::default(),
497        );
498    }
499}
500
501/// Project an aggressive-mode compression event into the OCLA CompressionProvider.
502/// Best-effort: silently drops if provider is unavailable or source_ref can't be
503/// constructed. This is the canonical production callsite for the compression capability.
504fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
505    use crate::core::ocla::OclaRegistry;
506    use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
507
508    let reg = OclaRegistry::global();
509    let source_ref = format!("file:{path}");
510    let request = CompressionRequest {
511        context: OclaRequestContext {
512            request_id: format!("cli-read-{}", path.len()),
513            session_id: SessionState::load_latest()
514                .map(|s| s.id)
515                .unwrap_or_default(),
516            agent_id: String::new(),
517            content_ref: source_ref.clone(),
518            tenant_id: None,
519            trace_id: "tr-unit".into(),
520        },
521        source_ref,
522        source_tokens,
523        target_tokens: output_tokens,
524        quality_policy_ref: None,
525    };
526    let _ = reg.compression_provider.compress(request);
527}
528
529/// Project a realized read-savings event into the OCLA SavingsLedger.
530/// Best-effort: silently drops if the provider is unavailable. Canonical
531/// production callsite for the savings-evidence capability.
532fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
533    use crate::core::ocla::OclaRegistry;
534    use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
535
536    let context = OclaRequestContext {
537        request_id: format!("cli-read-{}", path.len()),
538        session_id: SessionState::load_latest()
539            .map_or_else(|| "cli-read".to_string(), |session| session.id),
540        agent_id: "lean-ctx".to_string(),
541        content_ref: format!("file:{path}"),
542        tenant_id: None,
543        trace_id: String::new(),
544    };
545    let evidence = SavingsEvidence {
546        context,
547        original_tokens,
548        delivered_tokens: output_tokens,
549        quality_ref: None,
550        evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
551    };
552    let _ = OclaRegistry::global()
553        .savings_ledger
554        .record_savings(evidence);
555}
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use std::sync::atomic::{AtomicUsize, Ordering};
560
561    struct SpyAnalyzer {
562        calls: AtomicUsize,
563    }
564
565    impl crate::core::ocla::OclaService for SpyAnalyzer {
566        fn capability(&self) -> crate::core::ocla::OclaCapability {
567            crate::core::ocla::OclaCapability::available(
568                crate::core::ocla::OclaCapabilityKind::EfficiencyAnalyzer,
569            )
570        }
571    }
572
573    impl EfficiencyAnalyzer for SpyAnalyzer {
574        fn analyze_efficiency(
575            &self,
576            sample: crate::core::ocla::EfficiencySample,
577        ) -> crate::core::ocla::OclaResult<crate::core::ocla::EfficiencyAnalysis> {
578            self.calls.fetch_add(1, Ordering::Relaxed);
579            assert_eq!(sample.original_tokens, 1000);
580            assert_eq!(sample.delivered_tokens, 375);
581            Ok(crate::core::ocla::EfficiencyAnalysis {
582                etpao_milli: sample.accepted.map(|_| 375),
583                duplicate_ratio_milli: 625,
584                compression_rate_milli: 625,
585                cache_hit_rate_milli: 0,
586                recommendation_refs: Vec::new(),
587            })
588        }
589    }
590
591    // The record_* paths now drive process-global telemetry sinks (mode
592    // predictor buffer, anomaly singleton) and read the data-dir env (#550), so
593    // every test here takes the shared isolation lock to serialize that state
594    // and keep its disk writes inside a throwaway dir.
595
596    #[test]
597    fn ocla_read_density_uses_etpao_for_accepted_reads() {
598        let result = ocla_read_density("src/main.rs", "aggressive", 1000, 250, true, ".");
599        assert!(result.is_some(), "accepted read must produce ETPAO");
600        assert!(result.unwrap() > 0.0, "ETPAO must be positive");
601        assert_eq!(
602            ocla_read_density("src/main.rs", "aggressive", 1000, 250, false, "."),
603            None,
604            "unaccepted read must not produce ETPAO"
605        );
606    }
607
608    #[test]
609    fn ocla_read_density_accepts_injected_analyzer() {
610        let spy = SpyAnalyzer {
611            calls: AtomicUsize::new(0),
612        };
613        assert_eq!(
614            read_density_with_analyzer(&spy, "src/main.rs", "full", 1000, 375, true, "."),
615            Some(0.375)
616        );
617        assert_eq!(spy.calls.load(Ordering::Relaxed), 1);
618    }
619
620    #[test]
621    fn record_file_read_does_not_panic_without_session() {
622        let _dir = crate::core::data_dir::isolated_data_dir();
623        record_file_read(
624            "/tmp/nonexistent.rs",
625            "full",
626            100,
627            50,
628            false,
629            std::time::Duration::from_millis(1),
630            "excerpt",
631        );
632    }
633
634    #[test]
635    fn record_search_does_not_panic_without_session() {
636        let _dir = crate::core::data_dir::isolated_data_dir();
637        record_search(
638            500,
639            200,
640            150,
641            "pattern",
642            "/tmp",
643            std::time::Duration::from_millis(1),
644            "matches",
645        );
646    }
647
648    #[test]
649    fn record_tree_does_not_panic_without_session() {
650        let _dir = crate::core::data_dir::isolated_data_dir();
651        record_tree(100, 80);
652    }
653
654    #[test]
655    fn record_shell_does_not_panic_without_session() {
656        let _dir = crate::core::data_dir::isolated_data_dir();
657        record_shell_command(500, 200);
658    }
659
660    #[test]
661    fn flush_all_is_idempotent_and_safe_without_state() {
662        let _dir = crate::core::data_dir::isolated_data_dir();
663        // Empty buffers: flushing must be a harmless no-op, and calling it twice
664        // (e.g. a CLI arm followed by an atexit path) must never panic.
665        flush_all();
666        flush_all();
667    }
668
669    #[test]
670    fn cli_read_persists_learning_sinks_to_disk() {
671        // #550 regression: a single-shot CLI read must leave the mode predictor,
672        // compression feedback and heatmap on disk. The daemon used to be the
673        // only path that flushed them, so shadow-mode hook reads (`view`/`grep` →
674        // `lean-ctx read`) recorded nothing and `lean-ctx heatmap` stayed empty.
675        let dir = crate::core::data_dir::isolated_data_dir();
676        let file = dir.path().join("sample.rs");
677        std::fs::write(&file, "fn main() {\n    println!(\"hi\");\n}\n").unwrap();
678        let path = file.to_string_lossy();
679
680        record_file_read(
681            &path,
682            "full",
683            1000,
684            200,
685            false,
686            std::time::Duration::from_millis(2),
687            "sample.rs [3L]\nfn main() {}",
688        );
689        flush_all();
690
691        let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
692        let state = crate::core::paths::state_dir().expect("state dir");
693        assert!(
694            data.join("mode_stats.json").exists(),
695            "mode predictor must persist after a CLI read + flush"
696        );
697        assert!(
698            state.join("feedback.json").exists(),
699            "compression feedback must persist after a CLI read + flush"
700        );
701        assert!(
702            state.join("heatmap.json").exists(),
703            "heatmap must persist after a CLI read + flush"
704        );
705    }
706
707    #[test]
708    fn cli_read_records_context_ir_lineage() {
709        // #566: the MCP dispatcher records Context IR for every tool call, but the
710        // shadow-mode `lean-ctx read` subprocess used to skip it, so IR/ctx_proof
711        // exports were blind to compressed shadow reads. A single-shot CLI read
712        // must now persist exactly one IR item (disk-backed load→record→save).
713        let dir = crate::core::data_dir::isolated_data_dir();
714        let file = dir.path().join("ir_sample.rs");
715        std::fs::write(&file, "fn main() {}\n").unwrap();
716        let path = file.to_string_lossy();
717
718        record_file_read(
719            &path,
720            "full",
721            1000,
722            200,
723            false,
724            std::time::Duration::from_millis(3),
725            "ir_sample.rs [1L]\nfn main() {}",
726        );
727
728        let ir = ContextIrV1::load();
729        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
730        let item = &ir.items[0];
731        assert_eq!(item.source.tool, "ctx_read");
732        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
733        assert!(
734            item.source
735                .path
736                .as_deref()
737                .unwrap_or("")
738                .ends_with("ir_sample.rs"),
739            "IR records the read path, got {:?}",
740            item.source.path
741        );
742        assert_eq!(item.source.pattern.as_deref(), Some("full"));
743        assert_eq!(item.input_tokens, 1000);
744        assert_eq!(item.output_tokens, 200);
745        assert!(item.duration_us > 0, "a real duration must be recorded");
746        assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
747    }
748
749    #[test]
750    fn cli_search_records_context_ir_lineage() {
751        // #566: the shadow-mode `grep` → `lean-ctx grep` path records IR too.
752        let _dir = crate::core::data_dir::isolated_data_dir();
753
754        record_search(
755            800,
756            500,
757            120,
758            "fn handle",
759            "src/",
760            std::time::Duration::from_millis(4),
761            "src/lib.rs:12: fn handle() {}",
762        );
763
764        let ir = ContextIrV1::load();
765        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
766        let item = &ir.items[0];
767        assert_eq!(item.source.tool, "ctx_search");
768        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
769        // Input is the raw matched-line estimate, not the modeled baseline.
770        assert_eq!(item.input_tokens, 500);
771        assert_eq!(item.output_tokens, 120);
772        assert!(item.duration_us > 0, "a real duration must be recorded");
773    }
774}