Skip to main content

lean_ctx/tools/ctx_read/
dispatch.rs

1use super::{
2    CrpMode, HookPoint, PluginManager, ReadMode, ReadOutput, ReadTuning, SessionCache,
3    count_tokens, dedup_hook, handle_with_options_inner, kernel, protocol,
4};
5
6/// Reads a file through the cache and applies the requested compression mode.
7pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
8    handle_with_options(cache, path, mode, false, crp_mode, None)
9}
10
11/// Like `handle`, but invalidates the cache first to force a fresh disk read.
12pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
13    handle_with_options(cache, path, mode, true, crp_mode, None)
14}
15
16/// Reads a file with task-aware filtering to prioritize task-relevant content.
17pub fn handle_with_task(
18    cache: &mut SessionCache,
19    path: &str,
20    mode: &str,
21    crp_mode: CrpMode,
22    task: Option<&str>,
23) -> String {
24    let mut result = handle_with_options(cache, path, mode, false, crp_mode, task);
25    kernel::enrich_with_kernel(&mut result, task);
26    result
27}
28
29/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
30pub fn handle_with_task_resolved(
31    cache: &mut SessionCache,
32    path: &str,
33    mode: &str,
34    crp_mode: CrpMode,
35    task: Option<&str>,
36) -> ReadOutput {
37    handle_with_options_resolved(
38        cache,
39        path,
40        mode,
41        false,
42        crp_mode,
43        task,
44        ReadTuning::resolve(None, &[]),
45    )
46}
47
48/// Like [`handle_with_task_resolved`] but with an explicit per-call
49/// aggressiveness (the `ctx_read` `aggressiveness` arg, #714). `None` falls back
50/// to the `LEAN_CTX_AGGRESSIVENESS` env var / config field.
51pub fn handle_with_task_resolved_tuned(
52    cache: &mut SessionCache,
53    path: &str,
54    mode: &str,
55    crp_mode: CrpMode,
56    task: Option<&str>,
57    aggressiveness: Option<f64>,
58    protect: &[String],
59) -> ReadOutput {
60    handle_with_options_resolved(
61        cache,
62        path,
63        mode,
64        false,
65        crp_mode,
66        task,
67        ReadTuning::resolve(aggressiveness, protect),
68    )
69}
70
71/// Like [`handle_with_task_resolved_tuned`] but accepts pre-read file content,
72/// avoiding disk I/O under the cache write-lock (Two-Phase Read pattern, #1098).
73#[allow(clippy::too_many_arguments)]
74pub fn handle_with_preread(
75    cache: &mut SessionCache,
76    path: &str,
77    mode: &str,
78    fresh: bool,
79    crp_mode: CrpMode,
80    task: Option<&str>,
81    aggressiveness: Option<f64>,
82    protect: &[String],
83    preread: String,
84) -> ReadOutput {
85    handle_with_options_resolved_preread(
86        cache,
87        path,
88        mode,
89        fresh,
90        crp_mode,
91        task,
92        ReadTuning::resolve(aggressiveness, protect),
93        Some(preread),
94    )
95}
96
97/// Fresh read with task-aware filtering (invalidates cache first).
98pub fn handle_fresh_with_task(
99    cache: &mut SessionCache,
100    path: &str,
101    mode: &str,
102    crp_mode: CrpMode,
103    task: Option<&str>,
104) -> String {
105    handle_with_options(cache, path, mode, true, crp_mode, task)
106}
107
108/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
109pub fn handle_fresh_with_task_resolved(
110    cache: &mut SessionCache,
111    path: &str,
112    mode: &str,
113    crp_mode: CrpMode,
114    task: Option<&str>,
115) -> ReadOutput {
116    handle_with_options_resolved(
117        cache,
118        path,
119        mode,
120        true,
121        crp_mode,
122        task,
123        ReadTuning::resolve(None, &[]),
124    )
125}
126
127/// Fresh-read variant of [`handle_with_task_resolved_tuned`] (#714).
128pub fn handle_fresh_with_task_resolved_tuned(
129    cache: &mut SessionCache,
130    path: &str,
131    mode: &str,
132    crp_mode: CrpMode,
133    task: Option<&str>,
134    aggressiveness: Option<f64>,
135    protect: &[String],
136) -> ReadOutput {
137    handle_with_options_resolved(
138        cache,
139        path,
140        mode,
141        true,
142        crp_mode,
143        task,
144        ReadTuning::resolve(aggressiveness, protect),
145    )
146}
147
148fn handle_with_options(
149    cache: &mut SessionCache,
150    path: &str,
151    mode: &str,
152    fresh: bool,
153    crp_mode: CrpMode,
154    task: Option<&str>,
155) -> String {
156    handle_with_options_resolved(
157        cache,
158        path,
159        mode,
160        fresh,
161        crp_mode,
162        task,
163        ReadTuning::resolve(None, &[]),
164    )
165    .content
166}
167
168/// `LEAN_CTX_FORCE_FRESH=1` — an explicit operator override that always forces a
169/// cold full read, independent of conversation scoping.
170pub(crate) fn force_fresh_env() -> bool {
171    static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
172    *FORCE_FRESH.get_or_init(|| {
173        std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
174    })
175}
176
177/// Detects a subagent (forked agent) execution context.
178///
179/// A subagent must never be served a stub for content only the parent received.
180/// That used to be enforced by force-freshing *every* subagent read; with
181/// conversation scoping (#954/#955) the subagent instead runs under its own scope
182/// (`conversation::current_conversation_id` → `task:{id}` or `proc:{id}`), so
183/// the stub gate withholds cross-agent stubs precisely while restoring the
184/// subagent's *own* cheap re-reads. The blanket force-fresh is therefore kept
185/// only as the fallback when scoping is disabled (#956).
186///
187/// Checks `CURSOR_TASK_ID` (Cursor) and `CLAUDE_CODE_ENTRYPOINT=local-agent`
188/// (future Claude Code subagent marker). Current Claude Code is handled by
189/// per-process scoping in [`crate::core::conversation`] (#1292).
190pub(crate) fn is_subagent_context() -> bool {
191    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
192    *IS_SUBAGENT.get_or_init(|| {
193        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
194            || std::env::var("CLAUDE_CODE_ENTRYPOINT")
195                .ok()
196                .as_deref()
197                .map(str::trim)
198                == Some("local-agent")
199    })
200}
201
202/// Keeps subagent cache isolation while independently deciding whether a
203/// cross-agent delivery lookup may return a stub.
204#[allow(clippy::fn_params_excessive_bools)]
205pub(crate) fn effective_fresh_flags(
206    fresh: bool,
207    force_fresh: bool,
208    subagent_context: bool,
209    delivery_for_subagents: bool,
210) -> (bool, bool) {
211    let effective_fresh_for_cache = fresh || force_fresh || subagent_context;
212    let effective_fresh_for_delivery =
213        fresh || force_fresh || (subagent_context && !delivery_for_subagents);
214    (effective_fresh_for_cache, effective_fresh_for_delivery)
215}
216
217pub(crate) fn effective_fresh_for_delivery(fresh: bool) -> bool {
218    let config = crate::core::config::Config::load();
219    effective_fresh_flags(
220        fresh,
221        force_fresh_env(),
222        is_subagent_context(),
223        config.ocla.delivery.delivery_for_subagents,
224    )
225    .1
226}
227
228fn handle_with_options_resolved(
229    cache: &mut SessionCache,
230    path: &str,
231    mode: &str,
232    fresh: bool,
233    crp_mode: CrpMode,
234    task: Option<&str>,
235    tuning: ReadTuning<'_>,
236) -> ReadOutput {
237    handle_with_options_resolved_preread(cache, path, mode, fresh, crp_mode, task, tuning, None)
238}
239
240fn handle_with_options_resolved_preread(
241    cache: &mut SessionCache,
242    path: &str,
243    mode: &str,
244    fresh: bool,
245    crp_mode: CrpMode,
246    task: Option<&str>,
247    tuning: ReadTuning<'_>,
248    preread: Option<String>,
249) -> ReadOutput {
250    // Subagents retain isolated session caches, but can use delivery stubs when
251    // configured because those stubs explicitly identify another agent's read.
252    let config = crate::core::config::Config::load();
253    let (effective_fresh_for_cache, effective_fresh_for_delivery) = effective_fresh_flags(
254        fresh,
255        force_fresh_env(),
256        is_subagent_context(),
257        config.ocla.delivery.delivery_for_subagents,
258    );
259
260    let compress_protected = mode != "raw"
261        && !mode.starts_with("lines:")
262        && crate::core::config::Config::load()
263            .proxy
264            .is_path_compress_protected(path);
265
266    // Hash once for cross-agent delivery. The same snapshot is used for both
267    // the pre-read lookup and the post-read record, avoiding a second disk read.
268    let delivery_metadata = config
269        .ocla
270        .delivery_enabled()
271        .then(|| file_blake3_prefix(path))
272        .flatten();
273
274    if !effective_fresh_for_delivery
275        && !compress_protected
276        && let Some((hash, mtime)) = delivery_metadata
277        && let Some(stub) = try_cross_agent_stub(path, mode, hash, mtime)
278    {
279        cache.store(path, &stub.content);
280        cache.mark_full_delivered(path);
281        return stub;
282    }
283
284    if mode == "auto" {
285        let touched: Vec<String> = cache
286            .get_all_entries()
287            .iter()
288            .map(|(p, _)| (*p).clone())
289            .collect();
290        if crate::core::relevance_gate::should_gate(path, mode, task, &touched) {
291            let meta = std::fs::metadata(path);
292            let byte_count = meta.as_ref().map_or(0, std::fs::Metadata::len);
293            let line_count = preread
294                .as_ref()
295                .map_or(0, |c| bytecount::count(c.as_bytes(), b'\n'));
296            let stub = crate::core::relevance_gate::irrelevant_stub(path, line_count, byte_count);
297            let stub_tokens = count_tokens(&stub);
298            return ReadOutput {
299                content: stub,
300                resolved_mode: "auto".into(),
301                output_tokens: stub_tokens,
302                is_cache_hit: false,
303            };
304        }
305    }
306
307    if PluginManager::has_listener("pre_read") {
308        PluginManager::fire_hook_background(HookPoint::PreRead {
309            path: path.to_string(),
310        });
311    }
312
313    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
314        bt.next_seq();
315    }
316    let mut result = handle_with_options_inner(
317        cache,
318        path,
319        mode,
320        effective_fresh_for_cache,
321        crp_mode,
322        task,
323        tuning,
324        preread,
325    );
326
327    if let Some(entry) = cache.get_mut(path) {
328        entry.last_mode.clone_from(&result.resolved_mode);
329        if matches!(result.resolved_mode.as_str(), "full" | "full-compact")
330            && entry.full_content_delivered
331            && result.is_cache_hit
332            && entry.bump_reread() >= crate::core::cache::full_degradation_threshold()
333        {
334            entry.full_content_delivered = false;
335            entry.reset_reread_count();
336            crate::core::auto_mode_resolver::count_source("full_delivery_degraded");
337        }
338        // #841: a partial/filtered read means the model's most recent view is NOT
339        // the full content. Clear the delivery flag so a subsequent mode="full"
340        // re-delivers real content instead of the [unchanged] stub. Without this,
341        // a task→full sequence returns an empty stub because the flag was set by an
342        // earlier full delivery and never cleared by the intervening non-full read.
343        if !matches!(result.resolved_mode.as_str(), "full" | "full-compact") {
344            entry.full_content_delivered = false;
345            entry.reset_reread_count();
346        }
347    }
348
349    if !result.is_cache_hit
350        && let Some((hash, mtime)) = delivery_metadata
351    {
352        let line_count = cache.get(path).map_or(0, |entry| entry.line_count as u32);
353        record_cross_agent_delivery(path, hash, mtime, line_count, result.output_tokens);
354    }
355
356    // SSOT via [`ReadMode`] (#528): lossy summaries may elide shared blocks.
357    let dedup_allowed = result
358        .resolved_mode
359        .parse::<ReadMode>()
360        .is_ok_and(|m| m.is_lossy_summary());
361    if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
362        let new_tokens = count_tokens(&deduped);
363        if new_tokens < result.output_tokens {
364            result.content = deduped;
365            result.output_tokens = new_tokens;
366        }
367    }
368
369    // R28: Kernel content dedup — detect re-reads of unchanged content.
370    if let Some(stub) = dedup_hook::maybe_dedup(path, &result.content, mode, fresh) {
371        let stub_tokens = count_tokens(&stub);
372        if stub_tokens < result.output_tokens {
373            result.content = stub;
374            result.output_tokens = stub_tokens;
375            result.is_cache_hit = true;
376        }
377    }
378
379    // R30: Feed bounce-tracker signal into adaptive compression bridge.
380    crate::core::context_kernel::adaptive_hook::update_from_bounce_tracker();
381    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
382        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
383        bt.record_read(
384            path,
385            &result.resolved_mode,
386            result.output_tokens,
387            original_tokens,
388        );
389
390        // Quality signals (#538): compressed reads count as clean until a
391        // bounce proves otherwise (the bounce signal outweighs 6:1); large
392        // full reads of never-bouncing extensions are wasted compression
393        // opportunities and push the learned threshold up.
394        // SSOT via [`ReadMode`] (#528): only verbatim `full` and the `diff`
395        // delta are uncompressed. A resolved window is always the canonical
396        // `lines:N-M` (parses to `Lines` ⇒ compressed); the default of `true`
397        // for the unreachable bare `"lines"` keeps prior behaviour everywhere a
398        // real resolved mode can occur.
399        let compressed = result
400            .resolved_mode
401            .parse::<ReadMode>()
402            .map_or(true, |m| m.counts_as_compressed());
403        if compressed {
404            crate::core::adaptive_thresholds::record_quality_signal(
405                path,
406                crate::core::threshold_learning::QualitySignal::CleanCompressed,
407            );
408        } else if result.resolved_mode == "full"
409            && result.output_tokens > 2000
410            && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
411        {
412            crate::core::adaptive_thresholds::record_quality_signal(
413                path,
414                crate::core::threshold_learning::QualitySignal::WastedFull,
415            );
416        }
417    }
418
419    // Plugin seam: emit the realized compression stats. Same zero-cost guard.
420    if PluginManager::has_listener("post_compress") {
421        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
422        PluginManager::fire_hook_background(HookPoint::PostCompress {
423            path: path.to_string(),
424            original_tokens,
425            compressed_tokens: result.output_tokens,
426        });
427    }
428
429    // Stigmergy (#540): deposit a Hot scent for this read in the background
430    // (the field file lock may briefly block; never stall the read path). The
431    // foreign-claim hint is intentionally NOT appended to the body: it carries a
432    // relative timestamp ("claimed Nm ago"), which would make the output a
433    // non-pure function of wall-clock time and defeat provider prompt caching
434    // (#498). The deposit remains so the field still reflects active work.
435    {
436        let self_agent = crate::core::scent_field::scent_agent_id();
437        let scent_path = crate::core::pathutil::normalize_tool_path(path);
438        std::thread::spawn(move || {
439            crate::core::scent_field::deposit(
440                self_agent,
441                crate::core::scent_field::ScentKind::Hot,
442                &scent_path,
443                0.3,
444            );
445        });
446    }
447
448    crate::core::context_gc::maybe_gc(cache);
449
450    result
451}
452
453/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
454/// shared borrow of the cache.
455///
456/// Returns `None` when the file is not cached, was modified on disk, full
457/// content was never delivered, or the cache policy forbids stubbing — in those
458/// cases the caller must fall back to the write path.
459///
460/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
461/// dominant "re-read an unchanged file" case proceeds under a shared lock and
462/// parallel reads of distinct files no longer serialize on a global write lock.
463pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
464    // Resolve the caller *fresh* (TTL-bypassed): the stub gate's concurrency
465    // detection must see a just-appeared second chat with zero lag, else a stub
466    // could leak across chats in the pre-detection window (#1042).
467    let current_conversation = crate::core::conversation::current_conversation_id_fresh();
468    try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
469}
470
471/// Conversation-scoped core of [`try_stub_hit_readonly`]. The current
472/// conversation id is injected (not read from the global resolver) so the
473/// conversation gate can be tested deterministically without global state.
474pub(crate) fn try_stub_hit_readonly_scoped(
475    cache: &SessionCache,
476    path: &str,
477    current_conversation: Option<&str>,
478) -> Option<ReadOutput> {
479    let no_deg = crate::core::config::Config::load().no_degrade_effective();
480    let prof = crate::core::profiles::active_profile();
481    let force_full = no_deg
482        || (prof.read.default_mode_effective() == "full"
483            && prof.compression.crp_mode_effective() == "off");
484    let policy_allows_stub =
485        crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
486    if !policy_allows_stub {
487        return None;
488    }
489
490    // Warm path: a live in-memory entry is the freshest source of truth.
491    if let Some(file_ref) = cache.get_file_ref_readonly(path) {
492        let (cached_mtime, cached_hash, line_count, delivered_conv) = {
493            let entry = cache.get(path)?;
494            (
495                entry.stored_mtime,
496                entry.hash.clone(),
497                entry.line_count,
498                entry.delivered_conversation.clone(),
499            )
500        };
501        if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
502            || !cache.is_full_delivered(path)
503        {
504            return None;
505        }
506        // Conversation scoping (#954): only stub when THIS conversation received
507        // the content. A different (or unknown) conversation re-delivers in full
508        // rather than emit a misleading stub. `current == None` (hooks absent)
509        // preserves legacy process-scoped behavior, so single-chat hit rates are
510        // unchanged.
511        if !crate::core::conversation::conversation_allows_stub(
512            current_conversation,
513            delivered_conv.as_deref(),
514        ) {
515            crate::core::cache_telemetry::record_conversation_mismatch();
516            return None;
517        }
518        let original_tokens = cache.record_cache_hit(path)?.original_tokens;
519        crate::core::telemetry::global_metrics().record_cache(true);
520        let stub = render_unchanged_stub(&file_ref, path, line_count);
521        crate::core::stats::record_reread(original_tokens.saturating_sub(stub.output_tokens));
522        return Some(stub);
523    }
524
525    // Cold fallback (#955): no live entry (e.g. after a daemon restart or idle
526    // clear). Serve the stub from the persisted index iff the file is unchanged
527    // AND the *same known* conversation is asking — a stricter gate than the warm
528    // path, because a cold stub crosses a process boundary (no "no context →
529    // legacy" escape; see `conversation_allows_cold_stub`).
530    let rec = crate::core::read_stub_index::lookup(path)?;
531    if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
532        return None;
533    }
534    if !crate::core::conversation::conversation_allows_cold_stub(
535        current_conversation,
536        rec.delivered_conversation.as_deref(),
537    ) {
538        crate::core::cache_telemetry::record_conversation_mismatch();
539        return None;
540    }
541    Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
542}
543
544/// Renders the `[unchanged …]` stub body shared by the warm and cold stub paths.
545///
546/// #498 determinism: the stub is a pure function of (file_ref, path, line_count),
547/// so identical re-reads stay byte-stable and provider prompt caching applies.
548/// The `fresh=true` escape is a *static* suffix (no rotating proof lines or
549/// read-count notes), so a re-reader in non-meta mode still sees how to force the
550/// content (#513) without breaking byte-stability.
551fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
552    let short = protocol::shorten_path(path);
553    let out = if crate::core::protocol::meta_visible() {
554        format!(
555            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
556        )
557    } else {
558        format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
559    };
560    let out = crate::core::redaction::redact_text_if_enabled(&out);
561    let sent = count_tokens(&out);
562    ReadOutput {
563        content: out,
564        resolved_mode: "full".into(),
565        output_tokens: sent,
566        is_cache_hit: true,
567    }
568}
569
570/// Outcome of [`resolve_explicit_delta_mode`]: the (possibly rewritten) read
571/// mode plus an optional advisory note to surface to the agent.
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct DeltaExplicitDecision {
574    /// The mode the read should proceed with (rewritten only when the feature
575    /// fires; otherwise the caller's mode, unchanged).
576    pub mode: String,
577    /// A byte-stable advisory appended to the read body when the mode was
578    /// rewritten to `diff`. `None` when nothing was rewritten or the collapse
579    /// was a silent `lines:`→`full` stub.
580    pub note: Option<String>,
581}
582
583/// Decide whether an **explicit** `full`/`lines:N-M` re-read of a session-cached
584/// file should be served as a delta instead of re-emitting content the model
585/// already holds (the `delta_explicit` opt-in; env `LCTX_DELTA_EXPLICIT`).
586///
587/// Returns the mode the read should proceed with:
588/// - **Changed on disk** (verified mtime+md5 stale) and full content is cached →
589///   `diff`, plus an advisory note. The diff carries exactly the new
590///   information in a fraction of the tokens.
591/// - **Unchanged** and the request is `lines:` of an already-fully-delivered
592///   file → `full`, so the read collapses to the ~15-token `[unchanged]` stub
593///   instead of re-extracting a window the model has seen.
594/// - Otherwise the caller's `mode` is returned untouched.
595///
596/// First reads (nothing cached) and `fresh=true` are never affected — the
597/// caller gates those before calling. Staleness uses the **verified** variant
598/// ([`crate::core::cache::is_cache_entry_stale_verified`]) so a same-second
599/// write on a coarse-granularity filesystem cannot be mistaken for "unchanged"
600/// and yield a misleading empty diff (#498 determinism).
601///
602/// Pure w.r.t. (cache, path, mode, enabled): no wall-clock, counters, or
603/// randomness enter the result, so identical inputs stay byte-stable.
604pub fn resolve_explicit_delta_mode(
605    cache: &SessionCache,
606    path: &str,
607    mode: &str,
608    explicit_mode: bool,
609    fresh: bool,
610    enabled: bool,
611) -> DeltaExplicitDecision {
612    let unchanged = DeltaExplicitDecision {
613        mode: mode.to_string(),
614        note: None,
615    };
616    if fresh
617        || !enabled
618        || !explicit_mode
619        || !(mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
620    {
621        return unchanged;
622    }
623    let Some(entry) = cache.get(path) else {
624        // First read this session — nothing to diff against.
625        return unchanged;
626    };
627    let stale =
628        crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
629    if stale {
630        // Only divert to a diff when full content is actually cached: the diff
631        // base is that full content (see `handle_diff`), never a compressed
632        // view. Without it, `handle_diff` would have nothing to compare.
633        if entry.content().is_some() {
634            return DeltaExplicitDecision {
635                mode: "diff".to_string(),
636                note: Some(format!(
637                    "[delta-explicit] requested mode={mode} served as a diff: the file \
638                     changed since your last read and the diff is the new information. \
639                     Pass fresh=true if you need the full content re-emitted."
640                )),
641            };
642        }
643        return unchanged;
644    }
645    // Unchanged on disk: a `lines:` window of a file already delivered in full
646    // re-emits text the model holds — collapse to the full-mode stub
647    // (~15 tokens). A plain `full` re-read already hits that stub downstream.
648    if mode.starts_with("lines:") && cache.is_full_delivered(path) {
649        return DeltaExplicitDecision {
650            mode: "full".to_string(),
651            note: None,
652        };
653    }
654    unchanged
655}
656
657pub(crate) fn file_blake3_prefix(path: &str) -> Option<([u8; 12], u64)> {
658    let meta = std::fs::metadata(path).ok()?;
659    let mtime = meta
660        .modified()
661        .ok()?
662        .duration_since(std::time::UNIX_EPOCH)
663        .ok()?
664        .as_secs();
665    let bytes = std::fs::read(path).ok()?;
666    let hash = blake3::hash(&bytes);
667    let full = hash.as_bytes();
668    let mut prefix = [0u8; 12];
669    prefix.copy_from_slice(&full[..12]);
670    Some((prefix, mtime))
671}
672
673pub(crate) fn try_cross_agent_stub(
674    path: &str,
675    mode: &str,
676    hash: [u8; 12],
677    mtime: u64,
678) -> Option<ReadOutput> {
679    if !crate::core::config::Config::load().ocla.delivery_enabled() {
680        return None;
681    }
682    if matches!(mode, "full" | "raw" | "diff") {
683        return None;
684    }
685    let current_agent = std::env::var("CURSOR_TASK_ID")
686        .or_else(|_| std::env::var("CLAUDECODE"))
687        .unwrap_or_else(|_| "local-agent".to_string());
688    let current_conversation = crate::core::conversation::current_conversation_id()
689        .unwrap_or_else(|| current_agent.clone());
690    let reg = crate::core::ocla::OclaRegistry::global();
691    let record = crate::daemon_client::try_delivery_check_blocking(
692        &hash,
693        mtime,
694        path,
695        Some(&current_agent),
696        Some(&current_conversation),
697    )
698    .or_else(|| {
699        reg.delivery_registry.check_delivery(
700            &hash,
701            mtime,
702            path,
703            Some(&current_agent),
704            Some(&current_conversation),
705        )
706    })?;
707
708    let short = protocol::shorten_path(path);
709    let stub = format!(
710        "{short} [cross-agent · {lines}L · read by {agent} · use fresh=true to force]",
711        lines = record.line_count,
712        agent = record.agent_id,
713    );
714    let tokens = count_tokens(&stub);
715    reg.delivery_registry
716        .record_stub_served(&record, tokens as u64);
717    Some(ReadOutput {
718        content: stub,
719        resolved_mode: "cross-agent-stub".into(),
720        output_tokens: tokens,
721        is_cache_hit: true,
722    })
723}
724
725pub(crate) fn record_cross_agent_delivery(
726    path: &str,
727    hash: [u8; 12],
728    mtime: u64,
729    line_count: u32,
730    tokens: usize,
731) {
732    if !crate::core::config::Config::load().ocla.delivery_enabled() {
733        return;
734    }
735    let agent_id = std::env::var("CURSOR_TASK_ID")
736        .or_else(|_| std::env::var("CLAUDECODE"))
737        .unwrap_or_else(|_| "local-agent".to_string());
738    let conversation_id =
739        crate::core::conversation::current_conversation_id().unwrap_or_else(|| agent_id.clone());
740    let entry = crate::core::ocla::types::DeliveryEntry {
741        blake3: hash,
742        path: path.into(),
743        line_count,
744        token_count: tokens as u64,
745        agent_id,
746        conversation_id,
747        mtime,
748    };
749    crate::daemon_client::try_delivery_record_blocking(&entry);
750    let reg = crate::core::ocla::OclaRegistry::global();
751    reg.delivery_registry.record_delivery(entry);
752}
753
754#[cfg(test)]
755mod tests {
756    use super::{
757        SessionCache, effective_fresh_flags, try_cross_agent_stub, try_stub_hit_readonly_scoped,
758    };
759    use std::sync::atomic::Ordering;
760
761    #[test]
762    fn cross_agent_stub_miss_returns_none() {
763        let stub = try_cross_agent_stub("/nonexistent/file.rs", "auto", [0; 12], 0);
764        assert!(stub.is_none());
765    }
766
767    #[test]
768    fn subagent_delivery_policy_keeps_cache_fresh_but_allows_delivery_by_default() {
769        let delivery_for_subagents =
770            crate::core::config::DeliveryConfig::default().delivery_for_subagents;
771        assert!(
772            delivery_for_subagents,
773            "delivery must default to enabled for subagents"
774        );
775        let (cache_fresh, delivery_fresh) =
776            effective_fresh_flags(false, false, true, delivery_for_subagents);
777        assert!(cache_fresh, "subagent cache must remain isolated");
778        assert!(
779            !delivery_fresh,
780            "default policy must allow a cross-agent delivery lookup"
781        );
782    }
783
784    #[test]
785    fn subagent_delivery_policy_can_force_fresh_delivery() {
786        let (cache_fresh, delivery_fresh) = effective_fresh_flags(false, false, true, false);
787        assert!(cache_fresh);
788        assert!(delivery_fresh, "disabled policy must bypass delivery stubs");
789    }
790
791    #[test]
792    fn cross_agent_fallback_is_deterministic() {
793        // When no CURSOR_TASK_ID or CLAUDECODE env var is set, the fallback
794        // must be deterministic (not PID-based) for provider cache stability.
795        let _lock = crate::core::data_dir::test_env_lock();
796        crate::test_env::remove_var("CURSOR_TASK_ID");
797        crate::test_env::remove_var("CLAUDECODE");
798        let id1 = std::env::var("CURSOR_TASK_ID")
799            .or_else(|_| std::env::var("CLAUDECODE"))
800            .unwrap_or_else(|_| "local-agent".to_string());
801        let id2 = std::env::var("CURSOR_TASK_ID")
802            .or_else(|_| std::env::var("CLAUDECODE"))
803            .unwrap_or_else(|_| "local-agent".to_string());
804        assert_eq!(id1, id2, "fallback agent ID must be deterministic");
805        assert!(!id1.contains("proc:"), "must not contain PID");
806    }
807
808    #[test]
809    fn warm_stub_hit_records_central_telemetry() {
810        let dir = tempfile::tempdir().unwrap();
811        let file = dir.path().join("telemetry-hit.rs");
812        std::fs::write(&file, "fn telemetry_hit() {}\n").unwrap();
813        let path = file.to_string_lossy();
814        let mut cache = SessionCache::new();
815        cache.store(&path, "fn telemetry_hit() {}\n");
816        cache.mark_full_delivered(&path);
817
818        let metrics = crate::core::telemetry::global_metrics();
819        let before = metrics.cache_hits.load(Ordering::Relaxed);
820        let output = try_stub_hit_readonly_scoped(&cache, &path, None);
821        let after = metrics.cache_hits.load(Ordering::Relaxed);
822
823        assert!(output.is_some(), "warm re-read must use the stub cache");
824        assert!(
825            after > before,
826            "stub cache hit must increment central telemetry"
827        );
828    }
829}