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