Skip to main content

lean_ctx/server/
context_gate.rs

1use crate::core::context_field::{ContextItemId, ContextState};
2use crate::core::context_ledger::{ContextLedger, PressureAction};
3use crate::core::context_overlay::{OverlayOp, OverlayStore};
4
5#[derive(Debug, Clone)]
6pub struct PreDispatchResult {
7    pub overridden_mode: Option<String>,
8    pub reason: Option<&'static str>,
9    pub pressure_downgraded: bool,
10    pub budget_blocked: bool,
11    pub budget_warning: Option<String>,
12}
13
14#[derive(Debug, Clone)]
15pub struct PostDispatchResult {
16    pub eviction_hint: Option<String>,
17    pub elicitation_hint: Option<String>,
18    pub resource_changed: bool,
19}
20
21pub fn pre_dispatch_read(
22    path: &str,
23    requested_mode: &str,
24    task: Option<&str>,
25    project_root: Option<&str>,
26    pressure: Option<&PressureAction>,
27) -> PreDispatchResult {
28    pre_dispatch_read_for_agent(path, requested_mode, task, project_root, pressure, None)
29}
30
31pub fn pre_dispatch_read_for_agent(
32    path: &str,
33    requested_mode: &str,
34    task: Option<&str>,
35    project_root: Option<&str>,
36    pressure: Option<&PressureAction>,
37    agent_id: Option<&str>,
38) -> PreDispatchResult {
39    let no_change = PreDispatchResult {
40        overridden_mode: None,
41        reason: None,
42        pressure_downgraded: false,
43        budget_blocked: false,
44        budget_warning: None,
45    };
46
47    if let Some(aid) = agent_id {
48        let estimated_tokens = estimate_read_tokens(path, requested_mode);
49        match crate::core::agent_budget::check_budget(aid, estimated_tokens) {
50            crate::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
51                return PreDispatchResult {
52                    overridden_mode: None,
53                    reason: Some("agent-budget-exceeded"),
54                    pressure_downgraded: false,
55                    budget_blocked: true,
56                    budget_warning: Some(format!(
57                        "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
58                    )),
59                };
60            }
61            crate::core::agent_budget::BudgetCheckResult::Warning {
62                remaining,
63                percent_used,
64            } => {
65                let warning = format!(
66                    "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
67                    percent_used * 100.0
68                );
69                let mut result = no_change.clone();
70                result.budget_warning = Some(warning);
71                if requested_mode == "diff" || requested_mode.starts_with("lines") {
72                    return result;
73                }
74                let rest = pre_dispatch_inner(path, requested_mode, task, project_root, pressure);
75                return PreDispatchResult {
76                    budget_warning: result.budget_warning,
77                    ..rest
78                };
79            }
80            crate::core::agent_budget::BudgetCheckResult::Allowed { .. } => {}
81        }
82    }
83
84    pre_dispatch_inner(path, requested_mode, task, project_root, pressure)
85}
86
87fn pre_dispatch_inner(
88    path: &str,
89    requested_mode: &str,
90    task: Option<&str>,
91    project_root: Option<&str>,
92    pressure: Option<&PressureAction>,
93) -> PreDispatchResult {
94    let no_change = PreDispatchResult {
95        overridden_mode: None,
96        reason: None,
97        pressure_downgraded: false,
98        budget_blocked: false,
99        budget_warning: None,
100    };
101
102    if requested_mode == "diff" || requested_mode.starts_with("lines") {
103        return no_change;
104    }
105
106    if let Some(root) = project_root {
107        let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
108        if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
109            return result;
110        }
111    }
112
113    // Explicit mode=full must not be downgraded by pressure or other heuristics.
114    // Only overlays (user-explicit) above can override it.
115    if requested_mode == "full" {
116        return no_change;
117    }
118
119    if let Some(action) = pressure {
120        let no_degrade = crate::core::config::Config::load().no_degrade_effective();
121        let profile = crate::core::profiles::active_profile();
122        if !no_degrade
123            && profile.degradation.enforce_effective()
124            && let Some(downgraded) = pressure_downgrade(requested_mode, action)
125        {
126            return PreDispatchResult {
127                overridden_mode: Some(downgraded),
128                reason: Some("pressure-auto-downgrade"),
129                pressure_downgraded: true,
130                budget_blocked: false,
131                budget_warning: None,
132            };
133        }
134    }
135
136    if let Ok(bt) = crate::core::bounce_tracker::global().lock()
137        && bt.should_force_full(path)
138    {
139        return PreDispatchResult {
140            overridden_mode: Some("full".to_string()),
141            reason: Some("bounce-prevention"),
142            pressure_downgraded: false,
143            budget_blocked: false,
144            budget_warning: None,
145        };
146    }
147
148    if let Some(task_str) = task {
149        let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
150        let norm = crate::core::pathutil::normalize_tool_path(path);
151        let is_target = intent
152            .targets
153            .iter()
154            .any(|t| norm.ends_with(t) || norm.contains(t));
155        if is_target {
156            return PreDispatchResult {
157                overridden_mode: Some("full".to_string()),
158                reason: Some("intent-target"),
159                pressure_downgraded: false,
160                budget_blocked: false,
161                budget_warning: None,
162            };
163        }
164    }
165
166    if let Some(root) = project_root
167        && let Some(open) = try_load_graph(root)
168    {
169        let gp = &open.provider;
170        let related = gp.related(path, 1);
171        if let Some(task_str) = task {
172            let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
173            for target in &intent.targets {
174                let target_related = gp.related(target, 1);
175                let norm = crate::core::pathutil::normalize_tool_path(path);
176                if target_related
177                    .iter()
178                    .any(|r| r.contains(&norm) || norm.contains(r))
179                {
180                    return PreDispatchResult {
181                        overridden_mode: Some("map".to_string()),
182                        reason: Some("graph-direct-import"),
183                        pressure_downgraded: false,
184                        budget_blocked: false,
185                        budget_warning: None,
186                    };
187                }
188            }
189        }
190        if !related.is_empty() && requested_mode == "auto" {
191            let reverse_deps = gp.dependents(path);
192            if reverse_deps.len() > 3 {
193                return PreDispatchResult {
194                    overridden_mode: Some("map".to_string()),
195                    reason: Some("graph-hub-file"),
196                    pressure_downgraded: false,
197                    budget_blocked: false,
198                    budget_warning: None,
199                };
200            }
201        }
202    }
203
204    if let Some(root) = project_root
205        && let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root)
206    {
207        let norm = crate::core::pathutil::normalize_tool_path(path);
208        let mentions = knowledge
209            .facts
210            .iter()
211            .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
212            .count();
213        if mentions >= 3 {
214            return PreDispatchResult {
215                overridden_mode: Some("map".to_string()),
216                reason: Some("knowledge-high-relevance"),
217                pressure_downgraded: false,
218                budget_blocked: false,
219                budget_warning: None,
220            };
221        }
222    }
223
224    no_change
225}
226
227fn estimate_read_tokens(path: &str, mode: &str) -> usize {
228    let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
229    let char_estimate = file_size;
230    let full_tokens = char_estimate / 4;
231    match mode {
232        "signatures" => full_tokens / 5,
233        "map" => full_tokens / 3,
234        "aggressive" | "entropy" => full_tokens / 4,
235        "diff" => full_tokens / 10,
236        _ if mode.starts_with("lines:") => {
237            if let Some(range) = mode.strip_prefix("lines:") {
238                let parts: Vec<&str> = range.split('-').collect();
239                if parts.len() == 2 {
240                    let start = parts[0].parse::<usize>().unwrap_or(1);
241                    let end = parts[1].parse::<usize>().unwrap_or(start + 100);
242                    (end.saturating_sub(start) + 1) * 10
243                } else {
244                    full_tokens / 10
245                }
246            } else {
247                full_tokens / 10
248            }
249        }
250        _ => full_tokens,
251    }
252}
253
254fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
255    crate::core::auto_mode_resolver::pressure_downgrade(requested_mode, action)
256}
257
258fn check_overlay_mode_override(
259    path: &str,
260    requested_mode: &str,
261    overlay: &OverlayStore,
262) -> Option<PreDispatchResult> {
263    let item_id = ContextItemId::from_file(path);
264    let overlays = overlay.for_item(&item_id);
265
266    for ov in overlays.iter().rev() {
267        match &ov.operation {
268            OverlayOp::SetView(view) => {
269                let mode_str = view.as_str();
270                if mode_str != requested_mode {
271                    return Some(PreDispatchResult {
272                        overridden_mode: Some(mode_str.to_string()),
273                        reason: Some("overlay-set-view"),
274                        pressure_downgraded: false,
275                        budget_blocked: false,
276                        budget_warning: None,
277                    });
278                }
279            }
280            OverlayOp::Pin { .. } if requested_mode != "full" => {
281                return Some(PreDispatchResult {
282                    overridden_mode: Some("full".to_string()),
283                    reason: Some("pinned"),
284                    pressure_downgraded: false,
285                    budget_blocked: false,
286                    budget_warning: None,
287                });
288            }
289            OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
290                return Some(PreDispatchResult {
291                    overridden_mode: Some("signatures".to_string()),
292                    reason: Some("excluded"),
293                    pressure_downgraded: false,
294                    budget_blocked: false,
295                    budget_warning: None,
296                });
297            }
298            _ => {}
299        }
300    }
301    None
302}
303
304pub fn post_dispatch_record(
305    path: &str,
306    mode: &str,
307    original_tokens: usize,
308    sent_tokens: usize,
309    ledger: &mut ContextLedger,
310    overlay: &OverlayStore,
311) -> PostDispatchResult {
312    post_dispatch_record_with_task(
313        path,
314        mode,
315        original_tokens,
316        sent_tokens,
317        ledger,
318        overlay,
319        None,
320    )
321}
322
323pub fn post_dispatch_record_with_task(
324    path: &str,
325    mode: &str,
326    original_tokens: usize,
327    sent_tokens: usize,
328    ledger: &mut ContextLedger,
329    overlay: &OverlayStore,
330    task: Option<&str>,
331) -> PostDispatchResult {
332    let prev_count = ledger.entries.len();
333    let prev_pressure = ledger.pressure().recommendation;
334
335    ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
336
337    let item_id = ContextItemId::from_file(path);
338    let state = overlay.apply_to_state(&item_id, ContextState::Included);
339
340    if state == ContextState::Excluded {
341        return PostDispatchResult {
342            eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
343            elicitation_hint: None,
344            resource_changed: true,
345        };
346    }
347
348    let elicitation =
349        super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
350            .map(|s| s.format_fallback_hint());
351
352    let pressure = ledger.pressure();
353
354    apply_reinjection_plan(ledger, &pressure.recommendation);
355
356    let new_entry = ledger.entries.len() != prev_count;
357    let pressure_shifted = pressure.recommendation != prev_pressure;
358    let resource_changed = new_entry || pressure_shifted;
359
360    if pressure.utilization > 0.9 {
361        let candidates = ledger.eviction_candidates_by_phi(3);
362        if !candidates.is_empty() {
363            let names: Vec<_> = candidates
364                .iter()
365                .take(3)
366                .map(|p| crate::core::protocol::shorten_path(p))
367                .collect();
368            return PostDispatchResult {
369                eviction_hint: Some(format!(
370                    "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
371                    pressure.utilization * 100.0,
372                    names.join(", ")
373                )),
374                elicitation_hint: elicitation,
375                resource_changed,
376            };
377        }
378    }
379
380    PostDispatchResult {
381        eviction_hint: None,
382        elicitation_hint: elicitation,
383        resource_changed,
384    }
385}
386
387fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
388    if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
389    {
390        return;
391    }
392    for entry in &mut ledger.entries {
393        if entry.mode == "full" {
394            entry.mode = "map".to_string();
395        }
396    }
397}
398
399fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
400    crate::core::graph_provider::open_best_effort(project_root)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn pre_dispatch_passthrough_for_full() {
409        let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
410        assert!(result.overridden_mode.is_none());
411    }
412
413    #[test]
414    fn pre_dispatch_passthrough_for_diff() {
415        let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
416        assert!(result.overridden_mode.is_none());
417    }
418
419    #[test]
420    fn pre_dispatch_no_override_without_signals() {
421        let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
422        assert!(result.overridden_mode.is_none());
423    }
424
425    #[test]
426    fn pre_dispatch_bounce_prevention_forces_full() {
427        {
428            let mut bt = crate::core::bounce_tracker::global().lock().unwrap();
429            bt.set_seq(1);
430            bt.record_read("src/bouncy.yml", "map", 30, 400);
431            bt.set_seq(2);
432            bt.record_read("src/bouncy.yml", "full", 400, 400);
433            bt.set_seq(3);
434            bt.record_read("a2.yml", "map", 30, 400);
435            bt.set_seq(4);
436            bt.record_read("a2.yml", "full", 400, 400);
437            bt.set_seq(5);
438            bt.record_read("a3.yml", "map", 30, 400);
439            bt.set_seq(6);
440            bt.record_read("a3.yml", "full", 400, 400);
441        }
442        let result = pre_dispatch_read("new.yml", "auto", None, None, None);
443        assert_eq!(result.overridden_mode, Some("full".to_string()));
444        assert_eq!(result.reason, Some("bounce-prevention"));
445    }
446
447    #[test]
448    fn pressure_does_not_downgrade_explicit_full() {
449        let result = pre_dispatch_read(
450            "c.rs",
451            "full",
452            None,
453            None,
454            Some(&PressureAction::ForceCompression),
455        );
456        assert!(
457            result.overridden_mode.is_none(),
458            "explicit mode=full must never be downgraded by pressure"
459        );
460        assert!(!result.pressure_downgraded);
461    }
462
463    #[test]
464    fn pressure_does_not_downgrade_when_enforce_off() {
465        // Default profile has degradation.enforce = false, so pressure
466        // should NOT downgrade any mode.
467        let result = pre_dispatch_read(
468            "c.rs",
469            "map",
470            None,
471            None,
472            Some(&PressureAction::EvictLeastRelevant),
473        );
474        assert!(
475            result.overridden_mode.is_none(),
476            "pressure must not downgrade when degradation.enforce is off"
477        );
478        assert!(!result.pressure_downgraded);
479    }
480
481    #[test]
482    fn no_pressure_downgrade_when_low() {
483        let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
484        assert!(result.overridden_mode.is_none());
485        assert!(!result.pressure_downgraded);
486    }
487
488    #[test]
489    fn suggest_compression_does_not_downgrade_when_enforce_off() {
490        // Default profile has degradation.enforce = false
491        let result = pre_dispatch_read(
492            "c.rs",
493            "auto",
494            None,
495            None,
496            Some(&PressureAction::SuggestCompression),
497        );
498        assert!(
499            result.overridden_mode.is_none(),
500            "suggest_compression must not downgrade when enforce is off"
501        );
502        assert!(!result.pressure_downgraded);
503    }
504
505    #[test]
506    fn suggest_compression_does_not_touch_explicit_full() {
507        let result = pre_dispatch_read(
508            "c.rs",
509            "full",
510            None,
511            None,
512            Some(&PressureAction::SuggestCompression),
513        );
514        assert!(result.overridden_mode.is_none());
515        assert!(!result.pressure_downgraded);
516    }
517
518    #[test]
519    fn post_dispatch_reinjection_downgrades_entries() {
520        let mut ledger = ContextLedger::with_window_size(1000);
521        ledger.record("a.rs", "full", 400, 400);
522        ledger.record("b.rs", "full", 400, 400);
523        let overlay = OverlayStore::new();
524        let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
525        assert!(result.resource_changed);
526        let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
527        assert_eq!(a_entry.mode, "map");
528    }
529
530    #[test]
531    fn overlay_pin_forces_full_mode() {
532        let dir = tempfile::tempdir().expect("tmp dir");
533        let root = dir.path();
534        let mut store = OverlayStore::new();
535        let target = ContextItemId::from_file("src/important.rs");
536        store.add(crate::core::context_overlay::ContextOverlay::new(
537            target,
538            OverlayOp::Pin { verbatim: false },
539            crate::core::context_overlay::OverlayScope::Project,
540            String::new(),
541            crate::core::context_overlay::OverlayAuthor::User,
542        ));
543        store.save_project(root).unwrap();
544
545        let result = pre_dispatch_read(
546            "src/important.rs",
547            "auto",
548            None,
549            Some(root.to_str().unwrap()),
550            None,
551        );
552        assert_eq!(result.overridden_mode, Some("full".to_string()));
553        assert_eq!(result.reason, Some("pinned"));
554    }
555
556    #[test]
557    fn overlay_exclude_forces_signatures_mode() {
558        let dir = tempfile::tempdir().expect("tmp dir");
559        let root = dir.path();
560        let mut store = OverlayStore::new();
561        let target = ContextItemId::from_file("src/noisy.rs");
562        store.add(crate::core::context_overlay::ContextOverlay::new(
563            target,
564            OverlayOp::Exclude {
565                reason: "noise".to_string(),
566            },
567            crate::core::context_overlay::OverlayScope::Project,
568            String::new(),
569            crate::core::context_overlay::OverlayAuthor::User,
570        ));
571        store.save_project(root).unwrap();
572
573        let result = pre_dispatch_read(
574            "src/noisy.rs",
575            "auto",
576            None,
577            Some(root.to_str().unwrap()),
578            None,
579        );
580        assert_eq!(result.overridden_mode, Some("signatures".to_string()));
581        assert_eq!(result.reason, Some("excluded"));
582    }
583
584    // --- pressure_downgrade unit tests (pure function) ---
585
586    #[test]
587    fn pressure_downgrade_suggest_auto_to_map() {
588        let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
589        assert_eq!(result, Some("map".to_string()));
590    }
591
592    #[test]
593    fn pressure_downgrade_suggest_full_to_map() {
594        let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
595        assert_eq!(result, Some("map".to_string()));
596    }
597
598    #[test]
599    fn pressure_downgrade_suggest_does_not_touch_signatures() {
600        let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
601        assert!(result.is_none());
602    }
603
604    #[test]
605    fn pressure_downgrade_suggest_does_not_touch_diff() {
606        let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
607        assert!(result.is_none());
608    }
609
610    #[test]
611    fn pressure_downgrade_force_full_to_map() {
612        let result = pressure_downgrade("full", &PressureAction::ForceCompression);
613        assert_eq!(result, Some("map".to_string()));
614    }
615
616    #[test]
617    fn pressure_downgrade_force_auto_to_signatures() {
618        let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
619        assert_eq!(result, Some("signatures".to_string()));
620    }
621
622    #[test]
623    fn pressure_downgrade_force_map_to_signatures() {
624        let result = pressure_downgrade("map", &PressureAction::ForceCompression);
625        assert_eq!(result, Some("signatures".to_string()));
626    }
627
628    #[test]
629    fn pressure_downgrade_force_does_not_touch_signatures() {
630        let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
631        assert!(result.is_none());
632    }
633
634    #[test]
635    fn pressure_downgrade_force_does_not_touch_lines() {
636        let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
637        assert!(result.is_none());
638    }
639
640    #[test]
641    fn pressure_downgrade_evict_full_to_map() {
642        let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
643        assert_eq!(result, Some("map".to_string()));
644    }
645
646    #[test]
647    fn pressure_downgrade_evict_auto_to_signatures() {
648        let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
649        assert_eq!(result, Some("signatures".to_string()));
650    }
651
652    #[test]
653    fn pressure_downgrade_evict_map_to_signatures() {
654        let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
655        assert_eq!(result, Some("signatures".to_string()));
656    }
657
658    #[test]
659    fn pressure_downgrade_noaction_returns_none() {
660        let result = pressure_downgrade("full", &PressureAction::NoAction);
661        assert!(result.is_none());
662    }
663
664    #[test]
665    fn pressure_downgrade_noaction_auto_returns_none() {
666        let result = pressure_downgrade("auto", &PressureAction::NoAction);
667        assert!(result.is_none());
668    }
669
670    // --- pre_dispatch_inner: no_degrade integration ---
671    // When LCTX_NO_DEGRADE is NOT set (test default), pressure downgrade is active.
672
673    #[test]
674    fn pre_dispatch_does_not_downgrade_full_under_force() {
675        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
676            return;
677        }
678        // Explicit mode=full is protected: pressure cannot downgrade it
679        let result = pre_dispatch_read(
680            "nd_test.rs",
681            "full",
682            None,
683            None,
684            Some(&PressureAction::ForceCompression),
685        );
686        assert!(result.overridden_mode.is_none());
687        assert!(!result.pressure_downgraded);
688    }
689
690    #[test]
691    fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
692        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
693            return;
694        }
695        // Default profile has degradation.enforce = false, so pressure
696        // should not downgrade even non-full modes
697        let result = pre_dispatch_read(
698            "nd_test2.rs",
699            "auto",
700            None,
701            None,
702            Some(&PressureAction::EvictLeastRelevant),
703        );
704        assert!(result.overridden_mode.is_none());
705        assert!(!result.pressure_downgraded);
706    }
707
708    // --- estimate_read_tokens unit tests ---
709
710    #[test]
711    fn estimate_tokens_diff_mode_is_small() {
712        let tokens = estimate_read_tokens("nonexistent.rs", "diff");
713        assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
714    }
715
716    #[test]
717    fn estimate_tokens_signatures_smaller_than_full() {
718        let sig = estimate_read_tokens("nonexistent.rs", "signatures");
719        let full = estimate_read_tokens("nonexistent.rs", "full");
720        assert!(sig < full, "signatures={sig} should be < full={full}");
721    }
722
723    #[test]
724    fn estimate_tokens_lines_range() {
725        let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
726        assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
727    }
728
729    #[test]
730    fn overlay_set_view_forces_specified_mode() {
731        let dir = tempfile::tempdir().expect("tmp dir");
732        let root = dir.path();
733        let mut store = OverlayStore::new();
734        let target = ContextItemId::from_file("src/big.rs");
735        store.add(crate::core::context_overlay::ContextOverlay::new(
736            target,
737            OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
738            crate::core::context_overlay::OverlayScope::Project,
739            String::new(),
740            crate::core::context_overlay::OverlayAuthor::User,
741        ));
742        store.save_project(root).unwrap();
743
744        let result = pre_dispatch_read(
745            "src/big.rs",
746            "auto",
747            None,
748            Some(root.to_str().unwrap()),
749            None,
750        );
751        assert_eq!(result.overridden_mode, Some("map".to_string()));
752        assert_eq!(result.reason, Some("overlay-set-view"));
753    }
754}