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