Skip to main content

lean_ctx_server/tools/ctx_read/
dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2use super::{
3    CrpMode, ReadMode, ReadOutput, ReadTuning, SessionCache, count_tokens, dedup_hook,
4    handle_with_options_inner, protocol,
5};
6const MAX_RELAY_CONTENT_BYTES: usize = 8192;
7
8/// Cursor writes terminal output to `.cursor/projects/*/terminals/*.txt` and
9/// polls these files every ~3s. Without special handling, each poll triggers a
10/// full re-read because the conversation gate blocks stubs under concurrency
11/// (#1040). Terminal files are system infrastructure — not conversation-scoped
12/// — so they safely bypass the gate while the content-hash check still
13/// guarantees correctness.
14fn is_terminal_poll_file(path: &str) -> bool {
15    (path.contains("/.cursor/") || path.contains("\\.cursor\\"))
16        && (path.contains("/terminals/") || path.contains("\\terminals\\"))
17        && std::path::Path::new(path)
18            .extension()
19            .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
20}
21
22/// Modes whose compressed output is useful for cross-agent relay.
23const RELAY_ELIGIBLE_MODES: &[&str] = &["map", "signatures"];
24
25/// Extract relay-eligible content from a rendered read.
26fn relay_eligible<'a>(mode: &'a str, content: &'a str) -> (Option<&'a str>, Option<&'a str>) {
27    if RELAY_ELIGIBLE_MODES.contains(&mode) && content.len() <= MAX_RELAY_CONTENT_BYTES {
28        (Some(content), Some(mode))
29    } else {
30        (None, None)
31    }
32}
33
34fn relay_variant_matches(
35    mode: &str,
36    stored: &str,
37    crp_mode: CrpMode,
38    tuning: ReadTuning<'_>,
39) -> bool {
40    RELAY_ELIGIBLE_MODES.iter().any(|candidate| {
41        (mode == "auto" || mode == *candidate)
42            && stored
43                == super::compressed_cache_key(
44                    candidate,
45                    crp_mode,
46                    None,
47                    tuning.aggressiveness,
48                    tuning.protect,
49                )
50    })
51}
52/// Reads a file through the cache and applies the requested compression mode.
53pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
54    handle_with_options(cache, path, mode, false, crp_mode, None)
55}
56
57/// Like `handle`, but invalidates the cache first to force a fresh disk read.
58pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
59    handle_with_options(cache, path, mode, true, crp_mode, None)
60}
61
62/// Reads a file with task-aware filtering to prioritize task-relevant content.
63pub fn handle_with_task(
64    cache: &mut SessionCache,
65    path: &str,
66    mode: &str,
67    crp_mode: CrpMode,
68    task: Option<&str>,
69) -> String {
70    handle_with_task_result(cache, path, mode, crp_mode, task).content
71}
72
73/// Task-aware read with the structural cache-hit result retained for callers
74/// that need to account for each file in a batch independently.
75pub fn handle_with_task_result(
76    cache: &mut SessionCache,
77    path: &str,
78    mode: &str,
79    crp_mode: CrpMode,
80    task: Option<&str>,
81) -> ReadOutput {
82    let mut result = handle_with_options_resolved(
83        cache,
84        path,
85        mode,
86        false,
87        crp_mode,
88        task,
89        ReadTuning::resolve(None, &[]),
90    );
91    // #1993: a file's section carries the file and nothing else. Kernel
92    // context is task-scoped, not file-scoped, so a batch appends it once as
93    // its own trailer (see `kernel_trailer`) instead of inside file 1.
94    result.output_tokens = count_tokens(&result.content);
95    result
96}
97
98/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
99pub fn handle_with_task_resolved(
100    cache: &mut SessionCache,
101    path: &str,
102    mode: &str,
103    crp_mode: CrpMode,
104    task: Option<&str>,
105) -> ReadOutput {
106    handle_with_options_resolved(
107        cache,
108        path,
109        mode,
110        false,
111        crp_mode,
112        task,
113        ReadTuning::resolve(None, &[]),
114    )
115}
116
117/// Like [`handle_with_task_resolved`] but with an explicit per-call
118/// aggressiveness (the `ctx_read` `aggressiveness` arg, #714). `None` falls back
119/// to the `LEAN_CTX_AGGRESSIVENESS` env var / config field.
120pub fn handle_with_task_resolved_tuned(
121    cache: &mut SessionCache,
122    path: &str,
123    mode: &str,
124    crp_mode: CrpMode,
125    task: Option<&str>,
126    aggressiveness: Option<f64>,
127    protect: &[String],
128) -> ReadOutput {
129    handle_with_options_resolved(
130        cache,
131        path,
132        mode,
133        false,
134        crp_mode,
135        task,
136        ReadTuning::resolve(aggressiveness, protect),
137    )
138}
139
140/// Like [`handle_with_task_resolved_tuned`] but accepts pre-read file content,
141/// avoiding disk I/O under the cache write-lock (Two-Phase Read pattern, #1098).
142#[allow(clippy::too_many_arguments)]
143pub fn handle_with_preread(
144    cache: &mut SessionCache,
145    path: &str,
146    mode: &str,
147    fresh: bool,
148    crp_mode: CrpMode,
149    task: Option<&str>,
150    aggressiveness: Option<f64>,
151    protect: &[String],
152    preread: String,
153) -> ReadOutput {
154    handle_with_options_resolved_preread(
155        cache,
156        path,
157        mode,
158        fresh,
159        crp_mode,
160        task,
161        ReadTuning::resolve(aggressiveness, protect),
162        Some(preread),
163    )
164}
165
166/// Fresh read with task-aware filtering (invalidates cache first).
167pub fn handle_fresh_with_task(
168    cache: &mut SessionCache,
169    path: &str,
170    mode: &str,
171    crp_mode: CrpMode,
172    task: Option<&str>,
173) -> String {
174    handle_fresh_with_task_result(cache, path, mode, crp_mode, task).content
175}
176
177/// Fresh task-aware read with the structural cache-hit result retained.
178pub fn handle_fresh_with_task_result(
179    cache: &mut SessionCache,
180    path: &str,
181    mode: &str,
182    crp_mode: CrpMode,
183    task: Option<&str>,
184) -> ReadOutput {
185    handle_with_options_resolved(
186        cache,
187        path,
188        mode,
189        true,
190        crp_mode,
191        task,
192        ReadTuning::resolve(None, &[]),
193    )
194}
195
196/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
197pub fn handle_fresh_with_task_resolved(
198    cache: &mut SessionCache,
199    path: &str,
200    mode: &str,
201    crp_mode: CrpMode,
202    task: Option<&str>,
203) -> ReadOutput {
204    handle_with_options_resolved(
205        cache,
206        path,
207        mode,
208        true,
209        crp_mode,
210        task,
211        ReadTuning::resolve(None, &[]),
212    )
213}
214
215/// Fresh-read variant of [`handle_with_task_resolved_tuned`] (#714).
216pub fn handle_fresh_with_task_resolved_tuned(
217    cache: &mut SessionCache,
218    path: &str,
219    mode: &str,
220    crp_mode: CrpMode,
221    task: Option<&str>,
222    aggressiveness: Option<f64>,
223    protect: &[String],
224) -> ReadOutput {
225    handle_with_options_resolved(
226        cache,
227        path,
228        mode,
229        true,
230        crp_mode,
231        task,
232        ReadTuning::resolve(aggressiveness, protect),
233    )
234}
235
236fn handle_with_options(
237    cache: &mut SessionCache,
238    path: &str,
239    mode: &str,
240    fresh: bool,
241    crp_mode: CrpMode,
242    task: Option<&str>,
243) -> String {
244    handle_with_options_resolved(
245        cache,
246        path,
247        mode,
248        fresh,
249        crp_mode,
250        task,
251        ReadTuning::resolve(None, &[]),
252    )
253    .content
254}
255
256/// `LEAN_CTX_FORCE_FRESH=1` — an explicit operator override that always forces a
257/// cold full read, independent of conversation scoping.
258pub(crate) fn force_fresh_env() -> bool {
259    static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
260    *FORCE_FRESH.get_or_init(|| {
261        std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
262    })
263}
264
265pub(crate) use lean_ctx::core::conversation::is_subagent_context;
266
267/// Keeps subagent cache isolation while independently deciding whether a
268/// cross-agent delivery lookup may return a stub.
269#[allow(clippy::fn_params_excessive_bools)]
270pub(crate) fn effective_fresh_flags(
271    fresh: bool,
272    force_fresh: bool,
273    subagent_context: bool,
274    delivery_for_subagents: bool,
275) -> (bool, bool) {
276    let effective_fresh_for_cache = fresh || force_fresh || subagent_context;
277    let effective_fresh_for_delivery =
278        fresh || force_fresh || (subagent_context && !delivery_for_subagents);
279    (effective_fresh_for_cache, effective_fresh_for_delivery)
280}
281
282pub(crate) fn effective_fresh_for_delivery(fresh: bool) -> bool {
283    let config = lean_ctx::core::config::Config::load();
284    effective_fresh_flags(
285        fresh,
286        force_fresh_env(),
287        is_subagent_context(),
288        config.ocla.delivery.delivery_for_subagents,
289    )
290    .1
291}
292
293fn handle_with_options_resolved(
294    cache: &mut SessionCache,
295    path: &str,
296    mode: &str,
297    fresh: bool,
298    crp_mode: CrpMode,
299    task: Option<&str>,
300    tuning: ReadTuning<'_>,
301) -> ReadOutput {
302    handle_with_options_resolved_preread(cache, path, mode, fresh, crp_mode, task, tuning, None)
303}
304
305fn handle_with_options_resolved_preread(
306    cache: &mut SessionCache,
307    path: &str,
308    mode: &str,
309    fresh: bool,
310    crp_mode: CrpMode,
311    task: Option<&str>,
312    tuning: ReadTuning<'_>,
313    preread: Option<String>,
314) -> ReadOutput {
315    // Subagents retain isolated session caches, but can use delivery stubs when
316    // configured because those stubs explicitly identify another agent's read.
317    let config = lean_ctx::core::config::Config::load();
318    let (effective_fresh_for_cache, effective_fresh_for_delivery) = effective_fresh_flags(
319        fresh,
320        force_fresh_env(),
321        is_subagent_context(),
322        config.ocla.delivery.delivery_for_subagents,
323    );
324
325    // Instruction files are served in full (see `handle_with_options_inner`),
326    // so a relayed compressed view of another agent's read must not stand in.
327    let compress_protected = (mode != "raw"
328        && !mode.starts_with("lines:")
329        && lean_ctx::core::config::Config::load()
330            .proxy
331            .is_path_compress_protected(path))
332        || super::instruction_file_forces_full(path, mode);
333
334    // Hash once for cross-agent delivery. The same snapshot is used for both
335    // the pre-read lookup and the post-read record, avoiding a second disk read.
336    let delivery_metadata = config
337        .ocla
338        .delivery_enabled()
339        .then(|| file_blake3_prefix(path))
340        .flatten();
341
342    if !effective_fresh_for_delivery
343        && !compress_protected
344        && let Some(fp) = delivery_metadata
345        && let Some(stub) = try_cross_agent_stub(path, mode, fp.hash, fp.mtime, crp_mode, tuning)
346    {
347        return stub;
348    }
349
350    if mode == "auto" {
351        let touched: Vec<String> = cache
352            .get_all_entries()
353            .iter()
354            .map(|(p, _)| (*p).clone())
355            .collect();
356        if lean_ctx::core::relevance_gate::should_gate(path, mode, task, &touched) {
357            let meta = std::fs::metadata(path);
358            let byte_count = meta.as_ref().map_or(0, std::fs::Metadata::len);
359            let line_count = preread
360                .as_ref()
361                .map_or(0, |c| bytecount::count(c.as_bytes(), b'\n'));
362            let stub =
363                lean_ctx::core::relevance_gate::irrelevant_stub(path, line_count, byte_count);
364            let stub_tokens = count_tokens(&stub);
365            return ReadOutput {
366                content: stub,
367                resolved_mode: "auto".into(),
368                output_tokens: stub_tokens,
369                is_cache_hit: false,
370            };
371        }
372    }
373
374    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
375        bt.next_seq();
376    }
377    let mut result = handle_with_options_inner(
378        cache,
379        path,
380        mode,
381        effective_fresh_for_cache,
382        crp_mode,
383        task,
384        tuning,
385        preread,
386    );
387
388    if let Some(entry) = cache.get_mut(path) {
389        entry.last_mode.clone_from(&result.resolved_mode);
390        if matches!(result.resolved_mode.as_str(), "full" | "full-compact")
391            && entry.full_content_delivered
392            && result.is_cache_hit
393            && entry.bump_reread() >= lean_ctx::core::cache::full_degradation_threshold()
394        {
395            entry.full_content_delivered = false;
396            entry.reset_reread_count();
397            lean_ctx::core::auto_mode_resolver::count_source("full_delivery_degraded");
398        }
399        // #841: a partial/filtered read means the model's most recent view is NOT
400        // the full content. Clear the delivery flag so a subsequent mode="full"
401        // re-delivers real content instead of the [unchanged] stub. Without this,
402        // a task→full sequence returns an empty stub because the flag was set by an
403        // earlier full delivery and never cleared by the intervening non-full read.
404        // A full read the turn budget cuts reaches the model as a prefix only, so
405        // it is not a full delivery either (same rule as the dedup hook, #2172).
406        if !matches!(result.resolved_mode.as_str(), "full" | "full-compact")
407            || super::dedup_hook::exceeds_turn_budget(&result.content)
408        {
409            entry.full_content_delivered = false;
410            entry.reset_reread_count();
411        }
412    }
413
414    if !result.is_cache_hit
415        && let Some(fp) = delivery_metadata
416    {
417        record_read_delivery(
418            path,
419            fp,
420            &result.resolved_mode,
421            &result.content,
422            result.output_tokens,
423            crp_mode,
424            tuning,
425        );
426    }
427
428    // SSOT via [`ReadMode`] (#528): lossy summaries may elide shared blocks.
429    let dedup_allowed = result
430        .resolved_mode
431        .parse::<ReadMode>()
432        .is_ok_and(|m| m.is_lossy_summary());
433    if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
434        let new_tokens = count_tokens(&deduped);
435        if new_tokens < result.output_tokens {
436            result.content = deduped;
437            result.output_tokens = new_tokens;
438        }
439    }
440
441    // R28: Kernel content dedup — detect re-reads of unchanged content.
442    if let Some(stub) = dedup_hook::maybe_dedup(path, &result.content, mode, fresh) {
443        let stub_tokens = count_tokens(&stub);
444        if stub_tokens < result.output_tokens {
445            result.content = stub;
446            result.output_tokens = stub_tokens;
447            result.is_cache_hit = true;
448        }
449    }
450
451    // R30: Feed bounce-tracker signal into adaptive compression bridge.
452    lean_ctx::core::context_kernel::adaptive_hook::update_from_bounce_tracker();
453    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
454        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
455        // Bounce waste is reported when a provisional bounce is committed
456        // (`BounceTracker::commit`), not here: a read-then-edit cancels it.
457        bt.record_read(
458            path,
459            &result.resolved_mode,
460            result.output_tokens,
461            original_tokens,
462        );
463    }
464
465    lean_ctx::core::context_gc::maybe_gc(cache);
466
467    result
468}
469
470/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
471/// shared borrow of the cache.
472///
473/// Returns `None` when the file is not cached, was modified on disk, full
474/// content was never delivered, or the cache policy forbids stubbing — in those
475/// cases the caller must fall back to the write path.
476///
477/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
478/// dominant "re-read an unchanged file" case proceeds under a shared lock and
479/// parallel reads of distinct files no longer serialize on a global write lock.
480pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
481    // Resolve the caller *fresh* (TTL-bypassed): the stub gate's concurrency
482    // detection must see a just-appeared second chat with zero lag, else a stub
483    // could leak across chats in the pre-detection window (#1042).
484    let current_conversation = lean_ctx::core::conversation::current_conversation_id_fresh();
485    try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
486}
487
488/// Conversation-scoped core of [`try_stub_hit_readonly`]. The current
489/// conversation id is injected (not read from the global resolver) so the
490/// conversation gate can be tested deterministically without global state.
491pub(crate) fn try_stub_hit_readonly_scoped(
492    cache: &SessionCache,
493    path: &str,
494    current_conversation: Option<&str>,
495) -> Option<ReadOutput> {
496    if !stub_policy_allows() {
497        return None;
498    }
499
500    // Warm path: a live in-memory entry is the freshest source of truth.
501    if let Some(file_ref) = cache.get_file_ref_readonly(path) {
502        let (cached_mtime, cached_hash, line_count, delivered_conv) = {
503            let entry = cache.get(path)?;
504            (
505                entry.stored_mtime,
506                entry.hash.clone(),
507                entry.line_count,
508                entry.delivered_conversation.clone(),
509            )
510        };
511        if lean_ctx::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
512            || !cache.is_full_delivered(path)
513        {
514            return None;
515        }
516        // Terminal poll files bypass the conversation gate: they are system
517        // infrastructure, not conversation-scoped content. The content-hash
518        // verification above already guarantees the file is unchanged.
519        let is_terminal_poll = is_terminal_poll_file(path);
520
521        if !is_terminal_poll
522            && !lean_ctx::core::conversation::conversation_allows_stub(
523                current_conversation,
524                delivered_conv.as_deref(),
525            )
526        {
527            return None;
528        }
529        let original_tokens = cache.record_cache_hit(path)?.original_tokens;
530        lean_ctx::core::telemetry::global_metrics().record_cache(true);
531        let stub = render_unchanged_stub(&file_ref, path, line_count);
532        lean_ctx::core::stats::record_reread(original_tokens.saturating_sub(stub.output_tokens));
533        return Some(stub);
534    }
535
536    // Cold fallback (#955): no live entry (e.g. after a daemon restart or idle
537    // clear). Serve the stub from the persisted index iff the file is unchanged
538    // AND the *same known* conversation is asking — a stricter gate than the warm
539    // path, because a cold stub crosses a process boundary (no "no context →
540    // legacy" escape; see `conversation_allows_cold_stub`).
541    let rec = lean_ctx::core::read_stub_index::lookup(path)?;
542    if lean_ctx::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
543        return None;
544    }
545    if !is_terminal_poll_file(path)
546        && !lean_ctx::core::conversation::conversation_allows_cold_stub(
547            current_conversation,
548            rec.delivered_conversation.as_deref(),
549        )
550    {
551        return None;
552    }
553    Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
554}
555
556/// Whether the cache policy and active profile allow serving `[unchanged …]`
557/// stubs at all. Shared by the full-content stub path and the #1287 variant
558/// stub path so the two can never diverge.
559pub(crate) fn stub_policy_allows() -> bool {
560    let no_deg = lean_ctx::core::config::Config::load().no_degrade_effective();
561    let prof = lean_ctx::core::profiles::active_profile();
562    let force_full = no_deg
563        || (prof.read.default_mode_effective() == "full"
564            && prof.compression.crp_mode.as_deref() == Some("off"));
565    crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full
566}
567
568/// Renders the `[unchanged …]` stub body shared by the warm and cold stub paths.
569///
570/// #498 determinism: the stub is a pure function of (file_ref, path, line_count),
571/// so identical re-reads stay byte-stable and provider prompt caching applies.
572/// The `fresh=true` escape is a *static* suffix (no rotating proof lines or
573/// read-count notes), so a re-reader in non-meta mode still sees how to force the
574/// content (#513) without breaking byte-stability.
575fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
576    let short = protocol::shorten_path(path);
577    let out = if lean_ctx::core::protocol::meta_visible() {
578        format!(
579            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
580        )
581    } else {
582        format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
583    };
584    let out = lean_ctx::core::redaction::redact_text_if_enabled(&out);
585    let sent = count_tokens(&out);
586    ReadOutput {
587        content: out,
588        resolved_mode: "full".into(),
589        output_tokens: sent,
590        is_cache_hit: true,
591    }
592}
593
594/// #1287: renders the `[unchanged <mode> view]` stub for a compressed-variant
595/// re-read — same conversation, unchanged file, same mode key. Mirrors
596/// [`render_unchanged_stub`]'s #498 determinism contract: a pure function of
597/// (file_ref, path, mode), so identical re-reads stay byte-stable and the
598/// static `fresh=true` escape survives non-meta mode.
599pub(crate) fn render_unchanged_variant_stub(file_ref: &str, path: &str, mode: &str) -> ReadOutput {
600    let short = protocol::shorten_path(path);
601    let out = if lean_ctx::core::protocol::meta_visible() {
602        format!(
603            "{file_ref}={short} [unchanged {mode} view]\nSame {mode} output you already received. Use fresh=true to re-emit.",
604        )
605    } else {
606        format!("{file_ref}={short} [unchanged {mode} view · fresh=true to re-emit]")
607    };
608    let out = lean_ctx::core::redaction::redact_text_if_enabled(&out);
609    let sent = count_tokens(&out);
610    ReadOutput {
611        content: out,
612        resolved_mode: mode.to_string(),
613        output_tokens: sent,
614        is_cache_hit: true,
615    }
616}
617
618/// Outcome of [`resolve_explicit_delta_mode`]: the (possibly rewritten) read
619/// mode plus an optional advisory note to surface to the agent.
620#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct DeltaExplicitDecision {
622    /// The mode the read should proceed with (rewritten only when the feature
623    /// fires; otherwise the caller's mode, unchanged).
624    pub mode: String,
625    /// A byte-stable advisory appended to the read body when the mode was
626    /// rewritten to `diff`. `None` when nothing was rewritten or the collapse
627    /// was a silent `lines:`→`full` stub.
628    pub note: Option<String>,
629}
630
631/// Decide whether an **explicit** `full`/`lines:N-M` re-read of a session-cached
632/// file should be served as a delta instead of re-emitting content the model
633/// already holds (the `delta_explicit` opt-in; env `LCTX_DELTA_EXPLICIT`).
634///
635/// Returns the mode the read should proceed with:
636/// - **Changed on disk** (verified mtime+md5 stale) and full content is cached →
637///   `diff`, plus an advisory note. The diff carries exactly the new
638///   information in a fraction of the tokens.
639/// - **Unchanged** and the request is `lines:` of an already-fully-delivered
640///   file → `full`, so the read collapses to the ~15-token `[unchanged]` stub
641///   instead of re-extracting a window the model has seen.
642/// - Otherwise the caller's `mode` is returned untouched.
643///
644/// First reads (nothing cached) and `fresh=true` are never affected — the
645/// caller gates those before calling. Staleness uses the **verified** variant
646/// ([`lean_ctx::core::cache::is_cache_entry_stale_verified`]) so a same-second
647/// write on a coarse-granularity filesystem cannot be mistaken for "unchanged"
648/// and yield a misleading empty diff (#498 determinism).
649///
650/// Pure w.r.t. (cache, path, mode, enabled): no wall-clock, counters, or
651/// randomness enter the result, so identical inputs stay byte-stable.
652pub fn resolve_explicit_delta_mode(
653    cache: &SessionCache,
654    path: &str,
655    mode: &str,
656    explicit_mode: bool,
657    fresh: bool,
658    enabled: bool,
659) -> DeltaExplicitDecision {
660    let unchanged = DeltaExplicitDecision {
661        mode: mode.to_string(),
662        note: None,
663    };
664    if fresh
665        || !enabled
666        || !explicit_mode
667        || !(mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
668    {
669        return unchanged;
670    }
671    let Some(entry) = cache.get(path) else {
672        // First read this session — nothing to diff against.
673        return unchanged;
674    };
675    let stale =
676        lean_ctx::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
677    if stale {
678        // Only divert to a diff when full content is actually cached: the diff
679        // base is that full content (see `handle_diff`), never a compressed
680        // view. Without it, `handle_diff` would have nothing to compare.
681        if entry.content().is_some() {
682            return DeltaExplicitDecision {
683                mode: "diff".to_string(),
684                note: Some(format!(
685                    "[delta-explicit] requested mode={mode} served as a diff: the file \
686                     changed since your last read and the diff is the new information. \
687                     Pass fresh=true if you need the full content re-emitted."
688                )),
689            };
690        }
691        return unchanged;
692    }
693    // Unchanged on disk: a `lines:` window of a file already delivered in full
694    // re-emits text the model holds — collapse to the full-mode stub
695    // (~15 tokens). A plain `full` re-read already hits that stub downstream.
696    if mode.starts_with("lines:") && cache.is_full_delivered(path) {
697        return DeltaExplicitDecision {
698            mode: "full".to_string(),
699            note: None,
700        };
701    }
702    unchanged
703}
704
705pub(crate) fn file_blake3_prefix(path: &str) -> Option<DeliveryFingerprint> {
706    let meta = std::fs::metadata(path).ok()?;
707    let mtime = meta
708        .modified()
709        .ok()?
710        .duration_since(std::time::UNIX_EPOCH)
711        .ok()?
712        .as_secs();
713    let bytes = std::fs::read(path).ok()?;
714    let hash = blake3::hash(&bytes);
715    let full = hash.as_bytes();
716    let mut prefix = [0u8; 12];
717    prefix.copy_from_slice(&full[..12]);
718    Some(DeliveryFingerprint {
719        hash: prefix,
720        mtime,
721        line_count: line_count_of(&bytes),
722    })
723}
724
725/// Content snapshot taken once per read for cross-agent delivery: the lookup
726/// and the record use the same hash, mtime and line count (#1909: records made
727/// from the MCP path previously carried a hardcoded `0L`).
728#[derive(Clone, Copy, Debug, PartialEq, Eq)]
729pub(crate) struct DeliveryFingerprint {
730    pub hash: [u8; 12],
731    pub mtime: u64,
732    pub line_count: u32,
733}
734
735/// Line count with `str::lines` semantics (the `SessionCache` convention): a
736/// trailing newline does not open an extra line.
737fn line_count_of(bytes: &[u8]) -> u32 {
738    let newlines = bytecount::count(bytes, b'\n');
739    let unterminated = usize::from(bytes.last().is_some_and(|b| *b != b'\n'));
740    u32::try_from(newlines + unterminated).unwrap_or(u32::MAX)
741}
742
743/// The agent id delivery records and lookups are keyed on: the signing
744/// profile's principal when one is configured, otherwise the stable
745/// per-process agent identity (#1916), so two agents never share one id.
746fn delivery_agent(
747    profile: Option<&lean_ctx::core::a2a::task::delivery_authority::DeliverySigningProfileV1>,
748) -> String {
749    profile.map_or_else(
750        || lean_ctx::core::agent_identity::delivery_agent_id().to_string(),
751        |profile| profile.agent_id.clone(),
752    )
753}
754
755pub(crate) fn try_cross_agent_stub(
756    path: &str,
757    mode: &str,
758    hash: [u8; 12],
759    mtime: u64,
760    crp_mode: CrpMode,
761    tuning: ReadTuning<'_>,
762) -> Option<ReadOutput> {
763    if !lean_ctx::core::config::Config::load()
764        .ocla
765        .delivery_enabled()
766    {
767        return None;
768    }
769    if mode != "auto" && !RELAY_ELIGIBLE_MODES.contains(&mode) {
770        return None;
771    }
772    let profile =
773        lean_ctx::core::a2a::task::delivery_authority::DeliverySigningProfileV1::from_environment()
774            .ok()?;
775    let current_agent = delivery_agent(profile.as_ref());
776    let current_conversation = lean_ctx::core::conversation::current_conversation_id()
777        .unwrap_or_else(|| current_agent.clone());
778    let reg = lean_ctx::core::ocla::OclaRegistry::global();
779    let record = if let Some(profile) = profile {
780        // A scoped miss/error must never reveal a legacy cache entry.
781        lean_ctx::daemon_client::scoped_delivery_check_blocking(
782            &profile,
783            path,
784            hash,
785            &current_conversation,
786        )
787        .ok()
788        .flatten()
789    } else {
790        lean_ctx::daemon_client::try_delivery_check_blocking(
791            &hash,
792            mtime,
793            path,
794            Some(&current_agent),
795            Some(&current_conversation),
796        )
797        .or_else(|| {
798            reg.delivery_registry.check_delivery(
799                &hash,
800                mtime,
801                path,
802                Some(&current_agent),
803                Some(&current_conversation),
804            )
805        })
806    }?;
807
808    render_cross_agent_relay(path, mode, &record, crp_mode, tuning)
809}
810
811fn render_cross_agent_relay(
812    path: &str,
813    mode: &str,
814    record: &lean_ctx::core::ocla::types::DeliveryRecord,
815    crp_mode: CrpMode,
816    tuning: ReadTuning<'_>,
817) -> Option<ReadOutput> {
818    let relay_mode = record.relay_mode.as_deref()?;
819    if !relay_variant_matches(mode, relay_mode, crp_mode, tuning) {
820        return None;
821    }
822    // A record of somebody else's read is not itself usable context.
823    // Until reference expansion is available, a missing payload is a miss.
824    let content = record.relay_content.as_ref()?;
825    let short = protocol::shorten_path(path);
826
827    let header = format!(
828        "{short} [relayed from {} · {relay_mode} · {}L]",
829        record.agent_id, record.line_count,
830    );
831    let body = format!("{header}\n{content}");
832    let tokens = count_tokens(&body);
833    lean_ctx::core::ocla::OclaRegistry::global()
834        .delivery_registry
835        .record_stub_served(record, tokens as u64);
836    Some(ReadOutput {
837        content: body,
838        resolved_mode: "cross-agent-relay".into(),
839        output_tokens: tokens,
840        is_cache_hit: true,
841    })
842}
843
844/// Records a completed (non-cache-hit) read for cross-agent delivery, relaying
845/// the rendered view when it is a compact, relay-eligible one. Shared by the
846/// CLI/daemon dispatch path so both record the same fingerprint and relay key.
847pub(crate) fn record_read_delivery(
848    path: &str,
849    fingerprint: DeliveryFingerprint,
850    resolved_mode: &str,
851    content: &str,
852    output_tokens: usize,
853    crp_mode: CrpMode,
854    tuning: ReadTuning<'_>,
855) {
856    let relay = relay_eligible(resolved_mode, content);
857    let relay_key = relay.1.map(|mode| {
858        super::compressed_cache_key(mode, crp_mode, None, tuning.aggressiveness, tuning.protect)
859    });
860    record_cross_agent_delivery(
861        path,
862        fingerprint.hash,
863        fingerprint.mtime,
864        fingerprint.line_count,
865        output_tokens,
866        relay.0,
867        relay_key.as_deref(),
868    );
869}
870
871pub(crate) fn record_cross_agent_delivery(
872    path: &str,
873    hash: [u8; 12],
874    mtime: u64,
875    line_count: u32,
876    tokens: usize,
877    relay_content: Option<&str>,
878    relay_mode: Option<&str>,
879) {
880    if !lean_ctx::core::config::Config::load()
881        .ocla
882        .delivery_enabled()
883    {
884        return;
885    }
886    let Ok(profile) =
887        lean_ctx::core::a2a::task::delivery_authority::DeliverySigningProfileV1::from_environment()
888    else {
889        return;
890    };
891    let agent_id = delivery_agent(profile.as_ref());
892    let conversation_id =
893        lean_ctx::core::conversation::current_conversation_id().unwrap_or_else(|| agent_id.clone());
894    let mut entry = lean_ctx::core::ocla::types::DeliveryEntry {
895        access: None,
896        blake3: hash,
897        path: path.into(),
898        line_count,
899        token_count: tokens as u64,
900        agent_id,
901        conversation_id,
902        mtime,
903        relay_content: relay_content
904            .filter(|c| c.len() <= MAX_RELAY_CONTENT_BYTES)
905            .map(str::to_string),
906        relay_mode: relay_mode.map(str::to_string),
907    };
908    if let Some(profile) = profile {
909        let Ok(scope) = lean_ctx_ocla::delivery_scope::DeliveryScopeV1::new(
910            profile.tenant_id.clone(),
911            profile.project_id.clone(),
912        ) else {
913            return;
914        };
915        entry.access = Some(lean_ctx_ocla::delivery_scope::DeliveryAccessV1 {
916            scope,
917            privacy: profile.privacy,
918        });
919        let _ = lean_ctx::daemon_client::scoped_delivery_record_blocking(&profile, entry);
920        return;
921    }
922    lean_ctx::daemon_client::try_delivery_record_blocking(&entry);
923    let reg = lean_ctx::core::ocla::OclaRegistry::global();
924    reg.delivery_registry.record_delivery(entry);
925}
926
927#[cfg(test)]
928mod tests {
929    use super::{
930        CrpMode, ReadOutput, ReadTuning, SessionCache, effective_fresh_flags,
931        is_terminal_poll_file, relay_variant_matches, render_cross_agent_relay,
932        try_cross_agent_stub, try_stub_hit_readonly_scoped,
933    };
934    use std::sync::atomic::Ordering;
935
936    #[test]
937    fn cross_agent_stub_miss_returns_none() {
938        let stub = try_cross_agent_stub(
939            "/nonexistent/file.rs",
940            "auto",
941            [0; 12],
942            0,
943            CrpMode::Off,
944            ReadTuning::default(),
945        );
946        assert!(stub.is_none());
947    }
948
949    #[test]
950    fn relay_variants_preserve_requested_read_semantics() {
951        let defaults = ReadTuning::default();
952        assert!(relay_variant_matches(
953            "map",
954            "map:v2",
955            CrpMode::Off,
956            defaults
957        ));
958        assert!(relay_variant_matches(
959            "auto",
960            "signatures:v2",
961            CrpMode::Off,
962            defaults
963        ));
964        for mode in [
965            "signatures",
966            "lines:1-5",
967            "raw",
968            "full",
969            "task",
970            "reference",
971        ] {
972            assert!(!relay_variant_matches(
973                mode,
974                "map:v2",
975                CrpMode::Off,
976                defaults
977            ));
978        }
979        for stored in ["map", "map:v3", "mapping", "map:v2:tdd", "map:v2:unknown"] {
980            assert!(!relay_variant_matches(
981                "map",
982                stored,
983                CrpMode::Off,
984                defaults
985            ));
986        }
987        assert!(relay_variant_matches(
988            "map",
989            "map:v2:tdd",
990            CrpMode::Tdd,
991            defaults
992        ));
993        let protect = vec!["important".to_owned()];
994        let tuned = ReadTuning {
995            aggressiveness: Some(0.7),
996            protect: &protect,
997        };
998        let key = super::super::compressed_cache_key(
999            "map",
1000            CrpMode::Off,
1001            None,
1002            tuned.aggressiveness,
1003            tuned.protect,
1004        );
1005        assert!(relay_variant_matches("map", &key, CrpMode::Off, tuned));
1006        assert!(!relay_variant_matches("map", &key, CrpMode::Off, defaults));
1007        assert!(!relay_variant_matches("map", "map:v2", CrpMode::Off, tuned));
1008    }
1009
1010    #[test]
1011    fn relay_rendering_requires_matching_usable_payload() {
1012        let _isolated = lean_ctx::core::data_dir::isolated_data_dir();
1013        let mut record = lean_ctx::core::ocla::types::DeliveryRecord {
1014            access: None,
1015            blake3: [81; 12],
1016            path: "variant.rs".into(),
1017            line_count: 12,
1018            token_count: 200,
1019            agent_id: "source-agent".into(),
1020            conversation_id: "source-conversation".into(),
1021            read_at: 1,
1022            mtime: 1,
1023            relay_content: Some("fn shared_context()".into()),
1024            relay_mode: Some("map:v2".into()),
1025            fresh: true,
1026        };
1027        let render = |record: &lean_ctx::core::ocla::types::DeliveryRecord, mode| {
1028            render_cross_agent_relay(
1029                "variant.rs",
1030                mode,
1031                record,
1032                CrpMode::Off,
1033                ReadTuning::default(),
1034            )
1035        };
1036        let output = render(&record, "map").expect("matching relay");
1037        assert!(output.is_cache_hit);
1038        assert_eq!(output.resolved_mode, "cross-agent-relay");
1039        assert!(output.content.contains("fn shared_context()"));
1040        assert_eq!(output.output_tokens, super::count_tokens(&output.content));
1041        for mode in ["signatures", "lines:1-2", "raw", "full"] {
1042            assert!(render(&record, mode).is_none());
1043        }
1044        record.relay_mode = None;
1045        assert!(render(&record, "map").is_none());
1046        record.relay_mode = Some("map:v2".into());
1047        record.relay_content = None;
1048        assert!(render(&record, "map").is_none());
1049    }
1050
1051    #[test]
1052    fn invalid_signing_profile_never_reads_legacy_delivery() {
1053        const CHILD: &str = "LEAN_CTX_TEST_INVALID_DELIVERY_PROFILE";
1054        if std::env::var_os(CHILD).is_none() {
1055            let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1056                .args(["--exact", "tools::ctx_read::dispatch::tests::invalid_signing_profile_never_reads_legacy_delivery", "--nocapture"])
1057                .env(CHILD, "1")
1058                .env("LEAN_CTX_DELIVERY_PROFILE", "{invalid")
1059                .status().expect("child test");
1060            assert!(status.success());
1061            return;
1062        }
1063        let _isolated = lean_ctx::core::data_dir::isolated_data_dir();
1064        let registry = lean_ctx::core::ocla::OclaRegistry::global();
1065        let path = "/legacy-delivery-profile-regression";
1066        registry
1067            .delivery_registry
1068            .record_delivery(lean_ctx::core::ocla::types::DeliveryEntry {
1069                access: None,
1070                blake3: [93; 12],
1071                path: path.into(),
1072                line_count: 1,
1073                token_count: 100,
1074                agent_id: "legacy-owner".into(),
1075                conversation_id: "legacy-conversation".into(),
1076                mtime: 1,
1077                relay_content: Some("must not leak".into()),
1078                relay_mode: Some("map".into()),
1079            });
1080        assert!(
1081            registry
1082                .delivery_registry
1083                .check_delivery(&[93; 12], 1, path, None, None)
1084                .is_some()
1085        );
1086        assert!(
1087            try_cross_agent_stub(
1088                path,
1089                "auto",
1090                [93; 12],
1091                1,
1092                CrpMode::Off,
1093                ReadTuning::default()
1094            )
1095            .is_none()
1096        );
1097    }
1098
1099    #[test]
1100    fn subagent_delivery_policy_keeps_cache_fresh_but_allows_delivery_by_default() {
1101        let delivery_for_subagents =
1102            lean_ctx::core::config::DeliveryConfig::default().delivery_for_subagents;
1103        assert!(
1104            delivery_for_subagents,
1105            "delivery must default to enabled for subagents"
1106        );
1107        let (cache_fresh, delivery_fresh) =
1108            effective_fresh_flags(false, false, true, delivery_for_subagents);
1109        assert!(cache_fresh, "subagent cache must remain isolated");
1110        assert!(
1111            !delivery_fresh,
1112            "default policy must allow a cross-agent delivery lookup"
1113        );
1114    }
1115
1116    #[test]
1117    fn subagent_delivery_policy_can_force_fresh_delivery() {
1118        let (cache_fresh, delivery_fresh) = effective_fresh_flags(false, false, true, false);
1119        assert!(cache_fresh);
1120        assert!(delivery_fresh, "disabled policy must bypass delivery stubs");
1121    }
1122
1123    #[test]
1124    fn line_count_matches_str_lines_semantics() {
1125        for sample in ["", "a", "a\n", "a\nb", "a\nb\n", "\n\n", "a\r\nb\r\n"] {
1126            assert_eq!(
1127                super::line_count_of(sample.as_bytes()) as usize,
1128                sample.lines().count(),
1129                "line count diverged for {sample:?}"
1130            );
1131        }
1132    }
1133
1134    #[test]
1135    fn fingerprint_carries_real_line_count() {
1136        // #1909: the MCP path recorded a hardcoded 0L.
1137        let dir = tempfile::tempdir().unwrap();
1138        let file = dir.path().join("three.rs");
1139        std::fs::write(&file, "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
1140        let fp = super::file_blake3_prefix(file.to_str().unwrap()).unwrap();
1141        assert_eq!(fp.line_count, 3);
1142    }
1143
1144    /// Records a foreign legacy delivery for a fresh file and returns its path + key.
1145    fn foreign_delivery(
1146        relay: Option<(&str, &str)>,
1147    ) -> (tempfile::TempDir, String, super::DeliveryFingerprint) {
1148        let dir = tempfile::tempdir().unwrap();
1149        let file = dir.path().join("foreign.rs");
1150        std::fs::write(&file, "pub fn foreign() {}\npub fn other() {}\n").unwrap();
1151        let path = file.to_string_lossy().to_string();
1152        let fp = super::file_blake3_prefix(&path).unwrap();
1153        lean_ctx::core::ocla::OclaRegistry::global()
1154            .delivery_registry
1155            .record_delivery(lean_ctx::core::ocla::types::DeliveryEntry {
1156                access: None,
1157                blake3: fp.hash,
1158                path: path.clone(),
1159                line_count: fp.line_count,
1160                token_count: 40,
1161                agent_id: "claude-foreign".into(),
1162                conversation_id: "conv-foreign".into(),
1163                mtime: fp.mtime,
1164                relay_content: relay.map(|(c, _)| c.to_string()),
1165                relay_mode: relay.map(|(_, m)| m.to_string()),
1166            });
1167        (dir, path, fp)
1168    }
1169
1170    fn cross_agent(path: &str, mode: &str, fp: super::DeliveryFingerprint) -> Option<ReadOutput> {
1171        try_cross_agent_stub(
1172            path,
1173            mode,
1174            fp.hash,
1175            fp.mtime,
1176            CrpMode::Off,
1177            ReadTuning::default(),
1178        )
1179    }
1180
1181    #[test]
1182    fn content_free_stub_is_never_served_for_another_agents_read() {
1183        // #1909: agent B must get content, never a stub claiming content that
1184        // only agent A's context holds.
1185        let _isolated = lean_ctx::core::data_dir::isolated_data_dir();
1186        let (_dir, path, fp) = foreign_delivery(None);
1187        for mode in ["auto", "signatures", "map", "aggressive"] {
1188            assert!(
1189                cross_agent(&path, mode, fp).is_none(),
1190                "content-free cross-agent stub leaked for mode={mode}"
1191            );
1192        }
1193    }
1194
1195    #[test]
1196    fn relay_served_only_for_matching_mode() {
1197        let _isolated = lean_ctx::core::data_dir::isolated_data_dir();
1198        let relay = "pub fn foreign() {}";
1199        let (_dir, path, fp) = foreign_delivery(Some((relay, "map:v2")));
1200        let hit = cross_agent(&path, "map", fp).expect("map request must accept a map:v2 relay");
1201        assert!(hit.content.contains(relay), "{}", hit.content);
1202        assert!(hit.content.contains("· 2L]"), "{}", hit.content);
1203        assert!(
1204            cross_agent(&path, "signatures", fp).is_none(),
1205            "a signatures request must not be answered with a map relay"
1206        );
1207        assert!(cross_agent(&path, "lines:1-2", fp).is_none());
1208    }
1209
1210    #[test]
1211    fn warm_stub_hit_records_central_telemetry() {
1212        let dir = tempfile::tempdir().unwrap();
1213        let file = dir.path().join("telemetry-hit.rs");
1214        std::fs::write(&file, "fn telemetry_hit() {}\n").unwrap();
1215        let path = file.to_string_lossy();
1216        let mut cache = SessionCache::new();
1217        cache.store(&path, "fn telemetry_hit() {}\n");
1218        cache.mark_full_delivered(&path);
1219
1220        let metrics = lean_ctx::core::telemetry::global_metrics();
1221        let before = metrics.cache_hits.load(Ordering::Relaxed);
1222        let output = try_stub_hit_readonly_scoped(&cache, &path, None);
1223        let after = metrics.cache_hits.load(Ordering::Relaxed);
1224
1225        assert!(output.is_some(), "warm re-read must use the stub cache");
1226        assert!(
1227            after > before,
1228            "stub cache hit must increment central telemetry"
1229        );
1230    }
1231
1232    #[test]
1233    fn relay_does_not_poison_session_cache() {
1234        let mut cache = SessionCache::new();
1235        let path = "/tmp/test_relay_poison.rs";
1236        cache.store(path, "original content");
1237        assert_eq!(
1238            cache
1239                .get(path)
1240                .map(|e| e.compressed_outputs.contains_key("cross-agent-relay")),
1241            Some(false),
1242            "cross-agent-relay must not exist in session cache"
1243        );
1244    }
1245    #[test]
1246    fn is_terminal_poll_file_matches_cursor_terminal_paths() {
1247        assert!(is_terminal_poll_file(
1248            "/Users/me/.cursor/projects/Users-me-proj/terminals/12345.txt"
1249        ));
1250        assert!(is_terminal_poll_file(
1251            "/home/dev/.cursor/projects/foo/terminals/857272.txt"
1252        ));
1253        assert!(!is_terminal_poll_file("/Users/me/project/src/main.rs"));
1254        assert!(!is_terminal_poll_file(
1255            "/Users/me/.cursor/projects/foo/agent-transcripts/abc.jsonl"
1256        ));
1257        assert!(!is_terminal_poll_file("/Users/me/.cursor/terminals.log"));
1258    }
1259
1260    #[test]
1261    fn terminal_poll_file_stub_bypasses_conversation_gate() {
1262        let dir = tempfile::tempdir().unwrap();
1263        let terminals_dir = dir.path().join(".cursor").join("proj").join("terminals");
1264        std::fs::create_dir_all(&terminals_dir).unwrap();
1265        let file = terminals_dir.join("252028.txt");
1266        std::fs::write(&file, "---\npid: 1234\n---\nterminal output\n").unwrap();
1267        let path = file.to_string_lossy().to_string();
1268
1269        let mut cache = SessionCache::new();
1270        cache.store(&path, "---\npid: 1234\n---\nterminal output\n");
1271        cache.mark_full_delivered(&path);
1272
1273        // Mismatched conversation normally blocks stub — but terminal files bypass
1274        let output = try_stub_hit_readonly_scoped(&cache, &path, Some("different-conv"));
1275        assert!(
1276            output.is_some(),
1277            "terminal poll file must bypass conversation gate and serve stub"
1278        );
1279    }
1280
1281    #[test]
1282    fn non_terminal_file_respects_conversation_gate() {
1283        if lean_ctx::test_env::run_with_conversation_scope(
1284            "tools::ctx_read::dispatch::tests::non_terminal_file_respects_conversation_gate",
1285            true,
1286        ) {
1287            return;
1288        }
1289        let dir = tempfile::tempdir().unwrap();
1290        let file = dir.path().join("main.rs");
1291        std::fs::write(&file, "fn main() {}\n").unwrap();
1292        let path = file.to_string_lossy().to_string();
1293
1294        let mut cache = SessionCache::new();
1295        cache.store(&path, "fn main() {}\n");
1296        cache.mark_full_delivered(&path);
1297        if let Some(entry) = cache.get_mut(&path) {
1298            entry.delivered_conversation = Some("conv-A".to_string());
1299        }
1300
1301        // Mismatched conversation on regular file must block stub
1302        let output = try_stub_hit_readonly_scoped(&cache, &path, Some("conv-B"));
1303        assert!(
1304            output.is_none(),
1305            "regular file must respect conversation gate"
1306        );
1307    }
1308
1309    /// Owner decision 2026-10-31: a read leaves no scent in the field (the
1310    /// per-read Hot deposit cost a thread and a file lock per read).
1311    #[test]
1312    fn a_read_deposits_no_scent() {
1313        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1314        let project = tempfile::tempdir().unwrap();
1315        let file = project.path().join("scent_marker.rs");
1316        std::fs::write(&file, "pub fn scent_marker() {}\n").unwrap();
1317        let root = project.path().to_str().unwrap();
1318        let mut cache = SessionCache::new();
1319        let out = lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
1320            std::cell::RefCell::new(Some(project.path().to_path_buf())),
1321            || super::handle(&mut cache, file.to_str().unwrap(), "full", CrpMode::Off),
1322        );
1323        assert!(out.contains("scent_marker"), "{out}");
1324        assert!(
1325            lean_ctx::core::scent_field::sync_block(root).is_empty(),
1326            "a read must not deposit a scent"
1327        );
1328    }
1329}