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