Skip to main content

lean_ctx/tools/
autonomy.rs

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/// Per-key stats for progressive search hints (`ctx_search` / `ctx_semantic_search`).
23#[derive(Debug, Clone)]
24pub struct SearchHistory {
25    pub call_count: u32,
26    pub last_call: Instant,
27}
28
29/// Tracks autonomous action state: session init, dedup, and consolidation timing.
30pub struct AutonomyState {
31    pub session_initialized: AtomicBool,
32    pub dedup_applied: AtomicBool,
33    pub last_consolidation_unix: AtomicU64,
34    pub config: AutonomyConfig,
35    /// Repeated `pattern|path` keys for search tools (see [`AutonomyState::track_search`]).
36    pub search_repetition: Mutex<HashMap<String, SearchHistory>>,
37    /// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens).
38    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    /// Creates a new autonomy state with config loaded from disk.
49    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    /// Returns true if autonomous actions are enabled in configuration.
61    pub fn is_enabled(&self) -> bool {
62        self.config.enabled
63    }
64
65    /// Records a search (`pattern` + `path` key) and returns a progressive hint after repeated calls.
66    ///
67    /// Uses interior mutability so this can be called on `Arc<AutonomyState>`. Counters reset when
68    /// the idle gap since the last call for that key is at least five minutes (50ms in unit tests).
69    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
143/// Auto-preloads project context on the first tool call of a session.
144pub 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
233/// Appends related-file hints and silently preloads imports after a file read.
234pub 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/// Output from post-read enrichment: optional related-file hints.
314#[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        // Don't hydrate cloud placeholders during automatic import preload (#363).
382        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
395/// Runs cache deduplication once the entry count exceeds the configured threshold.
396pub 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
455/// Returns true if enough tool calls have elapsed to trigger auto-consolidation.
456pub 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
487/// `ctx_shell`: suggest sandbox / read modes when final output is large (bytes).
488pub 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
521/// `ctx_read` full mode: suggest compressed read modes when output is very large (tokens).
522pub 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
544/// Suggests a more token-efficient lean-ctx tool when shell compression is low.
545pub 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
589/// Applies `ctx_response` automatically for large outputs (guarded + bounded).
590/// Never runs on JSON outputs to avoid breaking machine-readable responses.
591pub 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}