1use std::collections::{HashMap, HashSet};
2use std::sync::Mutex;
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
6use crate::core::autonomy_drivers::{
7 AutonomyDriverDecisionV1, AutonomyDriverEventV1, AutonomyDriverKindV1, AutonomyPhaseV1,
8 AutonomyVerdictV1,
9};
10use crate::core::cache::SessionCache;
11use crate::core::config::AutonomyConfig;
12use crate::core::graph_provider::GraphProvider;
13use crate::core::protocol;
14use crate::core::tokens::count_tokens;
15use crate::tools::CrpMode;
16
17#[cfg(test)]
18const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_millis(500);
19#[cfg(not(test))]
20const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_mins(5);
21
22#[derive(Debug, Clone)]
24pub struct SearchHistory {
25 pub call_count: u32,
26 pub last_call: Instant,
27}
28
29pub struct AutonomyState {
31 pub session_initialized: AtomicBool,
32 pub dedup_applied: AtomicBool,
33 pub last_consolidation_unix: AtomicU64,
34 pub config: AutonomyConfig,
35 pub search_repetition: Mutex<HashMap<String, SearchHistory>>,
37 pub large_output_hints_shown: Mutex<HashSet<String>>,
39}
40
41impl Default for AutonomyState {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl AutonomyState {
48 pub fn new() -> Self {
50 Self {
51 session_initialized: AtomicBool::new(false),
52 dedup_applied: AtomicBool::new(false),
53 last_consolidation_unix: AtomicU64::new(0),
54 config: AutonomyConfig::load(),
55 search_repetition: Mutex::new(HashMap::new()),
56 large_output_hints_shown: Mutex::new(HashSet::new()),
57 }
58 }
59
60 pub fn is_enabled(&self) -> bool {
62 self.config.enabled
63 }
64
65 pub fn track_search(&self, pattern: &str, path: &str) -> Option<String> {
70 if !autonomy_enabled_effective(self) {
71 return None;
72 }
73 let key = format!("{pattern}|{path}");
74 let now = Instant::now();
75 let mut map = self
76 .search_repetition
77 .lock()
78 .unwrap_or_else(std::sync::PoisonError::into_inner);
79 let hist = map.entry(key).or_insert(SearchHistory {
80 call_count: 0,
81 last_call: now,
82 });
83 if hist.last_call.elapsed() >= SEARCH_REPEAT_IDLE_RESET {
84 hist.call_count = 0;
85 }
86 hist.call_count = hist.call_count.saturating_add(1);
87 hist.last_call = now;
88 let n = hist.call_count;
89
90 match n {
91 1..=3 => None,
92 4..=6 => Some(format!(
93 "[hint: repeated search ({n}/6). Consider ctx_knowledge remember to store findings]"
94 )),
95 _ => Some(format!(
96 "[throttle: search repeated {n} times on same pattern. Use ctx_pack or ctx_knowledge to consolidate]"
97 )),
98 }
99 }
100}
101
102fn profile_autonomy() -> crate::core::profiles::ProfileAutonomy {
103 crate::core::profiles::active_profile().autonomy
104}
105
106fn autonomy_enabled_effective(state: &AutonomyState) -> bool {
107 state.is_enabled() && profile_autonomy().enabled_effective()
108}
109
110fn policy_allows(tool: &str) -> Result<(), (String, String)> {
111 let policy = crate::core::degradation_policy::evaluate_v1_for_tool(tool, None);
112 match policy.decision.verdict {
113 crate::core::degradation_policy::DegradationVerdictV1::Ok
114 | crate::core::degradation_policy::DegradationVerdictV1::Warn => Ok(()),
115 crate::core::degradation_policy::DegradationVerdictV1::Throttle
116 | crate::core::degradation_policy::DegradationVerdictV1::Block => {
117 Err((policy.decision.reason_code, policy.decision.reason))
118 }
119 }
120}
121
122fn record_event(
123 phase: AutonomyPhaseV1,
124 tool: &str,
125 action: Option<&str>,
126 decisions: Vec<AutonomyDriverDecisionV1>,
127) {
128 let mut store = crate::core::autonomy_drivers::AutonomyDriversV1::load();
129 let ev = AutonomyDriverEventV1 {
130 seq: 0,
131 created_at: chrono::Utc::now().to_rfc3339(),
132 phase,
133 role: crate::core::roles::active_role_name(),
134 profile: crate::core::profiles::active_profile_name(),
135 tool: tool.to_string(),
136 action: action.map(std::string::ToString::to_string),
137 decisions,
138 };
139 store.record(ev);
140 let _ = store.save();
141}
142
143pub fn session_lifecycle_pre_hook(
145 state: &AutonomyState,
146 tool_name: &str,
147 cache: &mut SessionCache,
148 task: Option<&str>,
149 project_root: Option<&str>,
150 crp_mode: CrpMode,
151) -> Option<String> {
152 if !autonomy_enabled_effective(state) {
153 return None;
154 }
155
156 if tool_name == "ctx_overview" || tool_name == "ctx_preload" {
157 return None;
158 }
159
160 let prof = profile_autonomy();
161 let root = match project_root {
162 Some(r) if !r.is_empty() && r != "." => r.to_string(),
163 _ => return None,
164 };
165
166 if crate::core::memory_guard::is_under_pressure() {
169 return None;
170 }
171
172 if state
173 .session_initialized
174 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
175 .is_err()
176 {
177 return None;
178 }
179
180 let mut decisions = Vec::new();
181
182 if !state.config.auto_preload || !prof.auto_preload_effective() {
183 decisions.push(AutonomyDriverDecisionV1 {
184 driver: AutonomyDriverKindV1::Preload,
185 verdict: AutonomyVerdictV1::Skip,
186 reason_code: "disabled".to_string(),
187 reason: "auto_preload disabled by config/profile".to_string(),
188 detail: None,
189 });
190 record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
191 return None;
192 }
193
194 let chosen_tool = if task.is_some() {
195 "ctx_preload"
196 } else {
197 "ctx_overview"
198 };
199 if let Err((code, reason)) = policy_allows(chosen_tool) {
200 decisions.push(AutonomyDriverDecisionV1 {
201 driver: AutonomyDriverKindV1::Preload,
202 verdict: AutonomyVerdictV1::Skip,
203 reason_code: code,
204 reason,
205 detail: Some("policy guard (budget/slo)".to_string()),
206 });
207 record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
208 return None;
209 }
210
211 let result = if let Some(task_desc) = task {
212 crate::tools::ctx_preload::handle(cache, task_desc, Some(&root), crp_mode)
213 } else {
214 let cache_readonly = &*cache;
215 crate::tools::ctx_overview::handle(cache_readonly, None, Some(&root), crp_mode)
216 };
217
218 let empty = result.trim().is_empty()
219 || result.contains("No directly relevant files")
220 || result.contains("INDEXING IN PROGRESS");
221 decisions.push(AutonomyDriverDecisionV1 {
222 driver: AutonomyDriverKindV1::Preload,
223 verdict: AutonomyVerdictV1::Run,
224 reason_code: "session_start".to_string(),
225 reason: "first tool call in session".to_string(),
226 detail: Some(format!("tool={chosen_tool} empty={empty}")),
227 });
228 record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
229
230 if empty {
231 return None;
232 }
233
234 Some(format!(
235 "--- AUTO CONTEXT ---\n{result}\n--- END AUTO CONTEXT ---"
236 ))
237}
238
239pub fn enrich_after_read(
241 state: &AutonomyState,
242 cache: &mut SessionCache,
243 file_path: &str,
244 project_root: Option<&str>,
245 task: Option<&str>,
246 crp_mode: CrpMode,
247 minimal_overhead: bool,
248) -> EnrichResult {
249 let mut result = EnrichResult::default();
250
251 if !autonomy_enabled_effective(state) {
252 return result;
253 }
254
255 let prof = profile_autonomy();
256 let root = match project_root {
257 Some(r) if !r.is_empty() && r != "." => r.to_string(),
258 _ => return result,
259 };
260
261 let Some(open) = crate::core::graph_provider::open_or_build(&root) else {
262 return result;
263 };
264 let provider = &open.provider;
265 if provider.file_count() == 0 {
266 return result;
267 }
268
269 if state.config.auto_related && prof.auto_related_effective() {
270 result.related_hint = build_related_hints(cache, file_path, provider);
271 }
272
273 if state.config.silent_preload && prof.silent_preload_effective() {
274 silent_preload_imports(cache, file_path, provider, &root);
275 }
276
277 if !minimal_overhead && prof.auto_prefetch_effective() {
278 let mut decisions = Vec::new();
279 if let Err((code, reason)) = policy_allows("ctx_prefetch") {
280 decisions.push(AutonomyDriverDecisionV1 {
281 driver: AutonomyDriverKindV1::Prefetch,
282 verdict: AutonomyVerdictV1::Skip,
283 reason_code: code,
284 reason,
285 detail: Some("policy guard (budget/slo)".to_string()),
286 });
287 record_event(AutonomyPhaseV1::PostRead, "ctx_read", None, decisions);
288 } else {
289 let changed = vec![file_path.to_string()];
290 let out = crate::tools::ctx_prefetch::handle(
291 cache,
292 &root,
293 task,
294 Some(&changed),
295 prof.prefetch_budget_tokens_effective(),
296 Some(prof.prefetch_max_files_effective()),
297 crp_mode,
298 );
299 let summary = out.lines().next().unwrap_or("").trim().to_string();
300 decisions.push(AutonomyDriverDecisionV1 {
301 driver: AutonomyDriverKindV1::Prefetch,
302 verdict: AutonomyVerdictV1::Run,
303 reason_code: "after_read".to_string(),
304 reason: "bounded prefetch after ctx_read".to_string(),
305 detail: if summary.is_empty() {
306 None
307 } else {
308 Some(summary.clone())
309 },
310 });
311 record_event(AutonomyPhaseV1::PostRead, "ctx_read", None, decisions);
312 let _ = summary;
313 }
314 }
315
316 result
317}
318
319#[derive(Default)]
321pub struct EnrichResult {
322 pub related_hint: Option<String>,
323}
324
325fn build_related_hints(
326 cache: &SessionCache,
327 file_path: &str,
328 provider: &GraphProvider,
329) -> Option<String> {
330 let mut related: Vec<String> = Vec::new();
331 for path in provider
332 .dependencies(file_path)
333 .into_iter()
334 .chain(provider.dependents(file_path))
335 {
336 if related.len() >= 3 {
337 break;
338 }
339 if cache.get(&path).is_none() && !related.contains(&path) {
340 related.push(path);
341 }
342 }
343
344 if related.is_empty() {
345 return None;
346 }
347
348 let hints: Vec<String> = related.iter().map(|p| protocol::shorten_path(p)).collect();
349
350 Some(format!("[related: {}]", hints.join(", ")))
351}
352
353fn silent_preload_imports(
354 cache: &mut SessionCache,
355 file_path: &str,
356 provider: &GraphProvider,
357 project_root: &str,
358) {
359 let imports: Vec<String> = provider
360 .dependencies(file_path)
361 .into_iter()
362 .take(2)
363 .collect();
364
365 let jail_root = std::path::Path::new(project_root);
366 for path in imports {
367 let candidate = std::path::Path::new(&path);
368 let candidate = if candidate.is_absolute() {
369 candidate.to_path_buf()
370 } else {
371 jail_root.join(&path)
372 };
373 let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
374 "autonomy:silent_preload",
375 &candidate,
376 jail_root,
377 ) else {
378 continue;
379 };
380 if warning.is_some() {
381 continue;
382 }
383 let jailed_s = jailed.to_string_lossy().to_string();
384 if cache.get(&jailed_s).is_some() {
385 continue;
386 }
387 if crate::core::cloud_files::is_cloud_placeholder(&jailed) {
389 continue;
390 }
391
392 if let Ok(content) = std::fs::read_to_string(&jailed) {
393 let tokens = count_tokens(&content);
394 if tokens < 5000 {
395 cache.store(&jailed_s, &content);
396 }
397 }
398 }
399}
400
401pub fn maybe_auto_dedup(state: &AutonomyState, cache: &mut SessionCache, trigger_tool: &str) {
403 if !autonomy_enabled_effective(state) {
404 return;
405 }
406
407 let prof = profile_autonomy();
408 if !state.config.auto_dedup || !prof.auto_dedup_effective() {
409 return;
410 }
411
412 if state
413 .dedup_applied
414 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
415 .is_err()
416 {
417 return;
418 }
419
420 let entries = cache.get_all_entries();
421 let threshold = state
422 .config
423 .dedup_threshold
424 .max(prof.dedup_threshold_effective())
425 .max(1);
426 if entries.len() < threshold {
427 state.dedup_applied.store(false, Ordering::SeqCst);
428 return;
429 }
430
431 let mut decisions = Vec::new();
432 if let Err((code, reason)) = policy_allows("ctx_dedup") {
433 decisions.push(AutonomyDriverDecisionV1 {
434 driver: AutonomyDriverKindV1::Dedup,
435 verdict: AutonomyVerdictV1::Skip,
436 reason_code: code,
437 reason,
438 detail: Some("policy guard (budget/slo)".to_string()),
439 });
440 record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions);
441 state.dedup_applied.store(false, Ordering::SeqCst);
442 return;
443 }
444
445 let out = crate::tools::ctx_dedup::handle_action(cache, "apply");
446 let summary = out.lines().next().unwrap_or("").trim().to_string();
447 decisions.push(AutonomyDriverDecisionV1 {
448 driver: AutonomyDriverKindV1::Dedup,
449 verdict: AutonomyVerdictV1::Run,
450 reason_code: "threshold_reached".to_string(),
451 reason: format!("cache entries >= {threshold}"),
452 detail: if summary.is_empty() {
453 None
454 } else {
455 Some(summary)
456 },
457 });
458 record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions);
459}
460
461pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool {
463 if !state.is_enabled() || !state.config.auto_consolidate {
464 return false;
465 }
466 let every = state.config.consolidate_every_calls.max(1);
467 if !tool_calls.is_multiple_of(every) {
468 return false;
469 }
470
471 let now = SystemTime::now()
472 .duration_since(UNIX_EPOCH)
473 .map_or(0, |d| d.as_secs());
474 let last = state.last_consolidation_unix.load(Ordering::SeqCst);
475 if now.saturating_sub(last) < state.config.consolidate_cooldown_secs {
476 return false;
477 }
478 state.last_consolidation_unix.store(now, Ordering::SeqCst);
479 true
480}
481
482fn take_large_output_hint_once(state: &AutonomyState, key: &str) -> bool {
483 if !autonomy_enabled_effective(state) {
484 return false;
485 }
486 let mut set = state
487 .large_output_hints_shown
488 .lock()
489 .unwrap_or_else(std::sync::PoisonError::into_inner);
490 set.insert(key.to_string())
491}
492
493pub fn large_ctx_shell_output_hint(
495 state: &AutonomyState,
496 command: &str,
497 output_bytes: usize,
498) -> Option<String> {
499 const THRESHOLD_BYTES: usize = 5000;
500 if output_bytes <= THRESHOLD_BYTES {
501 return None;
502 }
503 if !take_large_output_hint_once(state, "ctx_shell_large_bytes") {
504 return None;
505 }
506 let n = output_bytes;
507 if shell_command_looks_structured(command) {
508 Some(format!(
509 "[hint: large output ({n} bytes). For structured output (e.g. cargo test, npm test, grep), use ctx_execute for automatic compression; for file contents use ctx_read(mode=\"aggressive\")]"
510 ))
511 } else {
512 Some(format!(
513 "[hint: large output ({n} bytes). Consider piping through ctx_execute for automatic compression, or use ctx_read(mode=\"aggressive\") for file contents]"
514 ))
515 }
516}
517
518fn shell_command_looks_structured(cmd: &str) -> bool {
519 let t = cmd.trim();
520 let lower = t.to_lowercase();
521 lower.contains("cargo test")
522 || lower.contains("npm test")
523 || t.starts_with("grep ")
524 || t.starts_with("rg ")
525}
526
527pub fn large_ctx_read_full_hint(
529 state: &AutonomyState,
530 mode: Option<&str>,
531 output: &str,
532) -> Option<String> {
533 const THRESHOLD_TOKENS: usize = 10_000;
534 let m = mode.unwrap_or("").trim();
535 if m != "full" {
536 return None;
537 }
538 let n = count_tokens(output);
539 if n <= THRESHOLD_TOKENS {
540 return None;
541 }
542 if !take_large_output_hint_once(state, "ctx_read_full_large_tokens") {
543 return None;
544 }
545 Some(format!(
546 "[hint: large file ({n} tokens). Consider mode=\"map\" or mode=\"aggressive\" for compressed view]"
547 ))
548}
549
550pub fn shell_efficiency_hint(
552 state: &AutonomyState,
553 command: &str,
554 input_tokens: usize,
555 output_tokens: usize,
556) -> Option<String> {
557 if !autonomy_enabled_effective(state) {
558 return None;
559 }
560
561 if input_tokens == 0 {
562 return None;
563 }
564
565 let savings_pct =
566 (input_tokens.saturating_sub(output_tokens) as f64 / input_tokens as f64) * 100.0;
567 if savings_pct >= 20.0 {
568 return None;
569 }
570
571 let cmd_lower = command.to_lowercase();
572 if cmd_lower.starts_with("grep ")
573 || cmd_lower.starts_with("rg ")
574 || cmd_lower.starts_with("find ")
575 || cmd_lower.starts_with("ag ")
576 {
577 return Some("[hint: ctx_search is more token-efficient for code search]".to_string());
578 }
579
580 if cmd_lower.starts_with("cat ") || cmd_lower.starts_with("head ") {
581 return Some("[hint: ctx_read provides cached, compressed file access]".to_string());
582 }
583
584 None
585}
586
587fn looks_like_json(text: &str) -> bool {
588 let t = text.trim();
589 if !(t.starts_with('{') || t.starts_with('[')) {
590 return false;
591 }
592 serde_json::from_str::<serde_json::Value>(t).is_ok()
593}
594
595pub fn maybe_auto_response(
598 state: &AutonomyState,
599 tool_name: &str,
600 action: Option<&str>,
601 output: &str,
602 crp_mode: CrpMode,
603 minimal_overhead: bool,
604) -> String {
605 if minimal_overhead || !autonomy_enabled_effective(state) {
606 return output.to_string();
607 }
608
609 let prof = profile_autonomy();
610 if !prof.auto_response_effective() {
611 return output.to_string();
612 }
613 if tool_name == "ctx_response" {
614 return output.to_string();
615 }
616
617 let input_tokens = count_tokens(output);
618 if input_tokens < prof.response_min_tokens_effective() {
619 return output.to_string();
620 }
621 if looks_like_json(output) {
622 record_event(
623 AutonomyPhaseV1::PostCall,
624 tool_name,
625 action,
626 vec![AutonomyDriverDecisionV1 {
627 driver: AutonomyDriverKindV1::Response,
628 verdict: AutonomyVerdictV1::Skip,
629 reason_code: "json_output".to_string(),
630 reason: "skip response shaping for JSON outputs".to_string(),
631 detail: None,
632 }],
633 );
634 return output.to_string();
635 }
636
637 if let Err((code, reason)) = policy_allows("ctx_response") {
638 record_event(
639 AutonomyPhaseV1::PostCall,
640 tool_name,
641 action,
642 vec![AutonomyDriverDecisionV1 {
643 driver: AutonomyDriverKindV1::Response,
644 verdict: AutonomyVerdictV1::Skip,
645 reason_code: code,
646 reason,
647 detail: Some("policy guard (budget/slo)".to_string()),
648 }],
649 );
650 return output.to_string();
651 }
652
653 let start = std::time::Instant::now();
654 let compressed = crate::tools::ctx_response::handle(output, crp_mode);
655 let duration = start.elapsed();
656 let output_tokens = count_tokens(&compressed);
657
658 let (verdict, reason_code, reason) = if compressed == output {
659 (
660 AutonomyVerdictV1::Skip,
661 "no_savings".to_string(),
662 "ctx_response made no changes".to_string(),
663 )
664 } else {
665 (
666 AutonomyVerdictV1::Run,
667 "output_large".to_string(),
668 "response shaping applied".to_string(),
669 )
670 };
671
672 record_event(
673 AutonomyPhaseV1::PostCall,
674 tool_name,
675 action,
676 vec![AutonomyDriverDecisionV1 {
677 driver: AutonomyDriverKindV1::Response,
678 verdict,
679 reason_code,
680 reason,
681 detail: Some(format!(
682 "tokens {}→{} in {:.1}ms",
683 input_tokens,
684 output_tokens,
685 duration.as_micros() as f64 / 1000.0
686 )),
687 }],
688 );
689
690 compressed
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn autonomy_state_starts_uninitialized() {
699 let state = AutonomyState::new();
700 assert!(!state.session_initialized.load(Ordering::SeqCst));
701 assert!(!state.dedup_applied.load(Ordering::SeqCst));
702 }
703
704 #[test]
705 fn session_initialized_fires_once() {
706 let state = AutonomyState::new();
707 let first = state.session_initialized.compare_exchange(
708 false,
709 true,
710 Ordering::SeqCst,
711 Ordering::SeqCst,
712 );
713 assert!(first.is_ok());
714 let second = state.session_initialized.compare_exchange(
715 false,
716 true,
717 Ordering::SeqCst,
718 Ordering::SeqCst,
719 );
720 assert!(second.is_err());
721 }
722
723 #[test]
724 fn shell_hint_for_grep() {
725 let state = AutonomyState::new();
726 let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 95);
727 assert!(hint.is_some());
728 assert!(hint.unwrap().contains("ctx_search"));
729 }
730
731 #[test]
732 fn shell_hint_none_when_good_savings() {
733 let state = AutonomyState::new();
734 let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 50);
735 assert!(hint.is_none());
736 }
737
738 #[test]
739 fn shell_hint_none_for_unknown_command() {
740 let state = AutonomyState::new();
741 let hint = shell_efficiency_hint(&state, "cargo build", 100, 95);
742 assert!(hint.is_none());
743 }
744
745 #[test]
746 fn large_shell_hint_once_per_session() {
747 let state = AutonomyState::new();
748 let h1 = large_ctx_shell_output_hint(&state, "ls -la", 5001).expect("first");
749 assert!(h1.contains("5001 bytes"));
750 assert!(h1.contains("ctx_execute"));
751 assert!(large_ctx_shell_output_hint(&state, "ls -la", 5001).is_none());
752 }
753
754 #[test]
755 fn large_shell_structured_hint_mentions_execute() {
756 let state = AutonomyState::new();
757 let h = large_ctx_shell_output_hint(&state, "cargo test", 6000).expect("hint");
758 assert!(h.contains("structured"));
759 assert!(h.contains("ctx_execute"));
760 }
761
762 #[test]
763 fn large_read_full_hint_respects_mode() {
764 let state = AutonomyState::new();
765 let big = "word ".repeat(20_000);
766 assert!(large_ctx_read_full_hint(&state, Some("map"), &big).is_none());
767 let h = large_ctx_read_full_hint(&state, Some("full"), &big).expect("hint");
768 assert!(h.contains("tokens"));
769 assert!(h.contains("aggressive"));
770 assert!(large_ctx_read_full_hint(&state, Some("full"), &big).is_none());
771 }
772
773 #[test]
774 fn large_hints_disabled_when_autonomy_off() {
775 let mut state = AutonomyState::new();
776 state.config.enabled = false;
777 let big = "word ".repeat(20_000);
778 assert!(large_ctx_shell_output_hint(&state, "cargo test", 6000).is_none());
779 assert!(large_ctx_read_full_hint(&state, Some("full"), &big).is_none());
780 }
781
782 #[test]
783 fn disabled_state_blocks_all() {
784 let mut state = AutonomyState::new();
785 state.config.enabled = false;
786 assert!(!state.is_enabled());
787 let hint = shell_efficiency_hint(&state, "grep foo", 100, 95);
788 assert!(hint.is_none());
789 }
790
791 #[test]
792 fn track_search_none_first_three() {
793 let _lock = crate::core::data_dir::test_env_lock();
794 let state = AutonomyState::new();
795 assert!(state.track_search("foo", "src").is_none());
796 assert!(state.track_search("foo", "src").is_none());
797 assert!(state.track_search("foo", "src").is_none());
798 }
799
800 #[test]
801 fn track_search_hint_band() {
802 let _lock = crate::core::data_dir::test_env_lock();
803 let state = AutonomyState::new();
804 for _ in 0..3 {
805 assert!(state.track_search("bar", ".").is_none());
806 }
807 let h = state.track_search("bar", ".").expect("hint on 4th");
808 assert!(h.starts_with("[hint: repeated search (4/6)."));
809 assert!(h.contains("ctx_knowledge"));
810 }
811
812 #[test]
813 fn track_search_throttle_seventh() {
814 let _lock = crate::core::data_dir::test_env_lock();
815 let state = AutonomyState::new();
816 for _ in 0..6 {
817 let _ = state.track_search("baz", "p");
818 }
819 let h = state.track_search("baz", "p").expect("throttle on 7th");
820 assert!(h.starts_with("[throttle: search repeated 7 times"));
821 assert!(h.contains("ctx_pack"));
822 }
823
824 #[test]
825 fn track_search_resets_after_idle() {
826 let _lock = crate::core::data_dir::test_env_lock();
827 let state = AutonomyState::new();
828 for _ in 0..3 {
829 assert!(state.track_search("idle", "x").is_none());
830 }
831 std::thread::sleep(std::time::Duration::from_millis(600));
832 assert!(
833 state.track_search("idle", "x").is_none(),
834 "count should reset after idle window"
835 );
836 }
837
838 #[test]
839 fn track_search_disabled_no_tracking_messages() {
840 let _lock = crate::core::data_dir::test_env_lock();
841 let mut state = AutonomyState::new();
842 state.config.enabled = false;
843 for _ in 0..8 {
844 assert!(state.track_search("q", "/").is_none());
845 }
846 }
847
848 #[test]
849 fn track_search_distinct_keys() {
850 let _lock = crate::core::data_dir::test_env_lock();
851 let state = AutonomyState::new();
852 assert!(state.track_search("pat", "a").is_none());
853 assert!(state.track_search("pat", "a").is_none());
854 assert!(state.track_search("pat", "a").is_none());
855 assert!(state.track_search("pat", "a").is_some());
856 assert!(state.track_search("pat", "b").is_none());
857 }
858}