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