Skip to main content

lean_ctx/server/
tool_visibility.rs

1//! Pure tool-visibility policy for the MCP `tools/list` response.
2//!
3//! Extracted from the (async, server-bound) `list_tools` handler so the policy
4//! is unit-testable in isolation. The handler resolves the candidate set
5//! (lazy-core vs profile-authoritative vs full registry) and the per-call gates
6//! (role, workflow), then defers to these helpers for the stable rules:
7//!   * Internal/meta tools are never advertised.
8//!   * The active profile, `disabled_tools`, and the per-client
9//!     [`ClientQuirks`] (Zed `ctx_edit`, native-editor `ctx_patch`) filter the
10//!     candidates.
11//!   * The universal invoker (`ctx_call`) is force-advertised in non-full mode so
12//!     tools hidden by lazy/profile filtering stay reachable.
13
14use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
15
16use super::dynamic_tools::{ToolCategory, categorize_tool};
17use crate::core::tool_profiles::ToolProfile;
18
19// ── Auto-profile session signals ─────────────────────────────
20
21static AUTO_TURN_COUNT: AtomicU64 = AtomicU64::new(0);
22static AUTO_CTX_TOOLS_USED: AtomicBool = AtomicBool::new(false);
23static AUTO_SYSTEM_PROMPT_TOKENS: AtomicUsize = AtomicUsize::new(0);
24
25/// Increment the Auto-profile turn counter (call once per tools/list request).
26pub fn record_auto_turn() {
27    AUTO_TURN_COUNT.fetch_add(1, Ordering::Relaxed);
28}
29
30/// Mark that the agent has invoked a ctx_* MCP tool in this session.
31pub fn mark_auto_ctx_tool_used() {
32    AUTO_CTX_TOOLS_USED.store(true, Ordering::Relaxed);
33}
34
35/// Update the system prompt token estimate for Auto-profile resolution.
36// TODO(#1354): remove dead code or implement
37pub fn set_auto_system_prompt_tokens(tokens: usize) {
38    AUTO_SYSTEM_PROMPT_TOKENS.store(tokens, Ordering::Relaxed);
39}
40
41/// Resolve `ToolProfile::Auto` to a concrete profile using session signals.
42/// Non-Auto profiles are returned as-is.
43#[must_use]
44pub fn resolve_auto_profile(profile: &ToolProfile) -> ToolProfile {
45    if *profile != ToolProfile::Auto {
46        return profile.clone();
47    }
48    ToolProfile::resolve_auto(
49        AUTO_TURN_COUNT.load(Ordering::Relaxed),
50        AUTO_CTX_TOOLS_USED.load(Ordering::Relaxed),
51        AUTO_SYSTEM_PROMPT_TOKENS.load(Ordering::Relaxed),
52    )
53}
54
55/// The universal invoker tool name. A static-list MCP client can call any
56/// registered tool through it, even when that tool isn't advertised.
57pub const INVOKER: &str = "ctx_call";
58
59/// Which candidate pool `tools/list` starts from, before per-tool gates run.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum CandidateSet {
62    /// Full registry (`LEAN_CTX_FULL_TOOLS=1` / `LEAN_CTX_LAZY_TOOLS=0`).
63    Full,
64    /// Consolidated unified surface (`LEAN_CTX_UNIFIED`).
65    Unified,
66    /// The user pinned a profile — it is authoritative and resolves against
67    /// the full registry (#358), so `standard` advertises its complete set.
68    ProfileAuthoritative,
69    /// Lean default: only `CORE_TOOL_NAMES` are advertised; everything else
70    /// stays reachable through [`INVOKER`] (#575).
71    LazyCore,
72    /// Hook-covered client: only the universal invoker (`ctx_call`) is
73    /// advertised. Native Read/Shell/Grep/Glob are compressed by installed
74    /// hooks; all other tools stay reachable through `ctx_call`.
75    ShadowOnly,
76}
77
78/// Inputs to [`candidate_set`]. Avoids a long bool parameter list (#clippy::fn_params_excessive_bools).
79pub struct CandidateInputs {
80    pub full_mode: bool,
81    pub unified_env: bool,
82    pub explicit_profile: bool,
83    pub hook_covered: bool,
84}
85
86/// Decides the candidate pool. Single source of truth for the `tools/list`
87/// handler AND offline measurement (`doctor overhead`), so the advertised
88/// surface and the reported overhead can never drift apart.
89#[must_use]
90pub fn candidate_set(inp: &CandidateInputs) -> CandidateSet {
91    if inp.full_mode {
92        CandidateSet::Full
93    } else if inp.unified_env {
94        CandidateSet::Unified
95    } else if inp.explicit_profile {
96        CandidateSet::ProfileAuthoritative
97    } else if inp.hook_covered && is_shadow_surface_enabled() {
98        CandidateSet::ShadowOnly
99    } else {
100        CandidateSet::LazyCore
101    }
102}
103
104/// Whether the shadow-only tool surface is enabled (config or env).
105fn is_shadow_surface_enabled() -> bool {
106    if let Ok(v) = std::env::var("LEAN_CTX_TOOL_SURFACE") {
107        return v.eq_ignore_ascii_case("shadow") || v.eq_ignore_ascii_case("auto");
108    }
109    let cfg = crate::core::config::Config::load();
110    // Only "mcp" explicitly disables shadow surface; "auto", "shadow", and
111    // unset all enable it (when the caller already confirmed hook_covered).
112    !matches!(cfg.tool_surface.as_deref(), Some("mcp"))
113}
114
115/// Whether the user explicitly pinned a tool profile (config key, custom tool
116/// list, or env var) — the trigger for [`CandidateSet::ProfileAuthoritative`].
117#[must_use]
118pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
119    cfg.tool_profile.is_some()
120        || !cfg.tools_enabled.is_empty()
121        || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
122}
123
124/// Client-specific advertising quirks, resolved once per `tools/list` from the
125/// MCP `clientInfo` name and the candidate set.
126///
127/// [`ClientQuirks::default`] (no quirks) is the "default client" used by
128/// offline measurement — the worst-case surface, nothing hidden.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub struct ClientQuirks {
131    /// Zed cannot handle `ctx_edit` (schema quirk) — hide it there.
132    pub hide_ctx_edit: bool,
133    /// Lazy-core only (#1008): the client ships a reliable native str-replace
134    /// editor, so the *default* surface skips `ctx_patch` — those sessions pay
135    /// zero extra schema tokens. A pinned profile is the user's explicit,
136    /// client-agnostic choice and always advertises its full set.
137    pub hide_ctx_patch: bool,
138}
139
140impl ClientQuirks {
141    /// Resolve the quirks for one `tools/list` answer.
142    #[must_use]
143    pub fn resolve(client_name: &str, candidate: CandidateSet) -> Self {
144        let lower = client_name.to_lowercase();
145        Self {
146            hide_ctx_edit: lower.contains("zed"),
147            hide_ctx_patch: candidate == CandidateSet::LazyCore && has_native_editor(&lower),
148        }
149    }
150}
151
152/// Clients whose built-in edit tool is reliable enough that the default
153/// (lazy-core) surface need not advertise `ctx_patch`: Cursor, Zed,
154/// Windsurf/Codeium, Antigravity, OpenCode. Everyone else gets the anchored
155/// editor — Claude Code (the hook read-redirect breaks its native
156/// read-before-write guard, #637), CodeBuddy, pi/SDK harnesses and
157/// unknown/headless clients that have no native editor at all.
158fn has_native_editor(lower_client_name: &str) -> bool {
159    [
160        "cursor",
161        "zed",
162        "windsurf",
163        "codeium",
164        "antigravity",
165        "opencode",
166    ]
167    .iter()
168    .any(|c| lower_client_name.contains(c))
169}
170
171/// Decides whether a tool name should appear in `tools/list`.
172///
173/// `role_allows` is supplied by the caller (it depends on the active role, which
174/// is resolved outside this pure function). Internal tools are hidden
175/// unconditionally — they're invoked automatically or via [`INVOKER`].
176#[must_use]
177pub fn is_tool_visible(
178    name: &str,
179    profile: &ToolProfile,
180    disabled: &[String],
181    quirks: ClientQuirks,
182    role_allows: bool,
183) -> bool {
184    if categorize_tool(name) == ToolCategory::Internal {
185        return false;
186    }
187    // #509: deprecated read-cluster aliases (ctx_smart_read, ctx_multi_read) are
188    // hidden from the advertised surface but stay callable for one release.
189    if super::dynamic_tools::is_deprecated_alias(name) {
190        return false;
191    }
192    if !profile.is_tool_enabled(name) {
193        return false;
194    }
195    if disabled.iter().any(|d| d == name) {
196        return false;
197    }
198    if quirks.hide_ctx_edit && name == "ctx_edit" {
199        return false;
200    }
201    if quirks.hide_ctx_patch && name == "ctx_patch" {
202        return false;
203    }
204    role_allows
205}
206
207/// Computes the tool set this install advertises to a default client
208/// (no client quirks, no role restriction, no workflow gate, static tool list),
209/// including the live description compression. Offline counterpart of the
210/// `tools/list` handler for `doctor overhead` / `ContextOverhead::measure` —
211/// kept next to the pure gates so measurement cannot drift from policy.
212/// "No quirks" is the worst case: a client without a native editor sees
213/// `ctx_patch` too, so the reported overhead never understates.
214#[must_use]
215pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
216    let cfg = crate::core::config::Config::load();
217    let disabled = cfg.disabled_tools_effective();
218    let profile = cfg.tool_profile_effective();
219    let full_mode = crate::tool_defs::is_full_mode();
220    let registry = crate::server::registry::build_registry();
221
222    let candidate = candidate_set(&CandidateInputs {
223        full_mode,
224        unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
225        explicit_profile: explicit_profile(&cfg),
226        hook_covered: false, // offline measurement uses worst-case (no hook coverage)
227    });
228    let pool: Vec<rmcp::model::Tool> = match candidate {
229        CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
230        CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
231        CandidateSet::ShadowOnly => registry
232            .tool_defs()
233            .into_iter()
234            .filter(|t| t.name.as_ref() == INVOKER)
235            .collect(),
236        CandidateSet::LazyCore => {
237            let core = crate::tool_defs::core_tool_names();
238            registry
239                .tool_defs()
240                .into_iter()
241                .filter(|t| core.contains(&t.name.as_ref()))
242                .collect()
243        }
244    };
245
246    let mut tools: Vec<_> = pool
247        .into_iter()
248        .filter(|t| {
249            is_tool_visible(
250                t.name.as_ref(),
251                &profile,
252                &disabled,
253                ClientQuirks::default(),
254                true,
255            )
256        })
257        .collect();
258
259    let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
260    if needs_invoker(full_mode, already, true, &disabled)
261        && let Some(def) = registry
262            .tool_defs()
263            .into_iter()
264            .find(|t| t.name.as_ref() == INVOKER)
265    {
266        tools.push(def);
267    }
268
269    let level = crate::core::config::CompressionLevel::effective(&cfg);
270    let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
271    if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
272        return tools;
273    }
274    tools
275        .into_iter()
276        .map(|mut t| {
277            let compressed = crate::core::terse::mcp_compress::compress_description(
278                t.name.as_ref(),
279                t.description.as_deref().unwrap_or(""),
280                mode,
281            );
282            t.description = Some(compressed.into());
283            t
284        })
285        .collect()
286}
287
288/// Whether the lazy per-category gate should filter the advertised tool set.
289///
290/// The dynamic-tools category gate (load tools on demand, signalled via
291/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
292/// surface small for capable clients. An explicit profile is the user's chosen,
293/// authoritative surface, so it must be advertised in full — otherwise category
294/// gating silently drops profile-enabled tools (e.g. Standard's
295/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
296/// advertised set stops matching `lean-ctx tools show` (#358).
297#[must_use]
298pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
299    supports_list_changed && !explicit_profile
300}
301
302/// Whether [`INVOKER`] must be force-added to the advertised set.
303///
304/// True only in non-full mode when it isn't already present, the role permits
305/// it, and it isn't explicitly disabled. In full mode every tool is already
306/// listed, so no gateway is needed.
307#[must_use]
308pub fn needs_invoker(
309    full_mode: bool,
310    already_present: bool,
311    invoker_role_allowed: bool,
312    disabled: &[String],
313) -> bool {
314    !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    /// No client quirks — the default/measurement client.
322    fn no_quirks() -> ClientQuirks {
323        ClientQuirks::default()
324    }
325
326    #[test]
327    fn internal_tools_never_visible_even_in_power() {
328        // Power enables everything, but Internal/meta tools must still be hidden.
329        let p = ToolProfile::Power;
330        assert!(!is_tool_visible("ctx_metrics", &p, &[], no_quirks(), true));
331        assert!(!is_tool_visible("ctx_cost", &p, &[], no_quirks(), true));
332        assert!(!is_tool_visible(
333            "ctx_discover_tools",
334            &p,
335            &[],
336            no_quirks(),
337            true
338        ));
339    }
340
341    #[test]
342    fn deprecated_aliases_never_visible_even_in_power() {
343        // #509: folded read-cluster aliases are hidden from tools/list in every
344        // mode (Power enables everything) — but stay registered + callable.
345        let p = ToolProfile::Power;
346        assert!(!is_tool_visible(
347            "ctx_smart_read",
348            &p,
349            &[],
350            no_quirks(),
351            true
352        ));
353        assert!(!is_tool_visible(
354            "ctx_multi_read",
355            &p,
356            &[],
357            no_quirks(),
358            true
359        ));
360    }
361
362    #[test]
363    fn deprecated_aliases_stay_registered_and_callable() {
364        // The non-breaking contract (#509): hidden from the advertised surface,
365        // but still in the registry so direct calls and ctx_call keep working
366        // for one release. Removal is Phase 2.
367        let _guard = crate::core::data_dir::isolated_data_dir();
368        let defs = crate::server::registry::build_registry().tool_defs();
369        for name in [
370            "ctx_smart_read",
371            "ctx_multi_read",
372            "ctx_semantic_search",
373            "ctx_symbol",
374        ] {
375            assert!(
376                defs.iter().any(|t| t.name.as_ref() == name),
377                "{name} must stay registered (callable) even though hidden"
378            );
379            assert!(
380                !is_tool_visible(name, &ToolProfile::Power, &[], no_quirks(), true),
381                "{name} must be hidden from tools/list"
382            );
383        }
384    }
385
386    #[test]
387    fn core_tool_visible_under_power() {
388        assert!(is_tool_visible(
389            "ctx_read",
390            &ToolProfile::Power,
391            &[],
392            no_quirks(),
393            true
394        ));
395    }
396
397    #[test]
398    fn standard_exposes_its_advertised_tools() {
399        // These are in STANDARD_TOOLS but were dropped by the old
400        // `core ∩ standard` intersection. Profile-authoritative resolution must
401        // surface them.
402        let p = ToolProfile::Standard;
403        assert!(is_tool_visible("ctx_execute", &p, &[], no_quirks(), true));
404        assert!(is_tool_visible("ctx_explore", &p, &[], no_quirks(), true));
405        assert!(is_tool_visible("ctx_callgraph", &p, &[], no_quirks(), true));
406        assert!(is_tool_visible("ctx_graph", &p, &[], no_quirks(), true));
407        // #1008: anchored editing ships with the pinned Standard profile.
408        assert!(is_tool_visible("ctx_patch", &p, &[], no_quirks(), true));
409    }
410
411    #[test]
412    fn folded_search_aliases_never_visible() {
413        // #509: ctx_semantic_search + ctx_symbol are consolidated into ctx_search
414        // (action=…). Hidden from tools/list in every mode, but stay callable.
415        let p = ToolProfile::Power;
416        assert!(!is_tool_visible(
417            "ctx_semantic_search",
418            &p,
419            &[],
420            no_quirks(),
421            true
422        ));
423        assert!(!is_tool_visible("ctx_symbol", &p, &[], no_quirks(), true));
424        assert!(is_tool_visible("ctx_search", &p, &[], no_quirks(), true));
425    }
426
427    #[test]
428    fn minimal_hides_non_minimal_tools() {
429        let p = ToolProfile::Minimal;
430        assert!(is_tool_visible("ctx_read", &p, &[], no_quirks(), true));
431        assert!(!is_tool_visible(
432            "ctx_architecture",
433            &p,
434            &[],
435            no_quirks(),
436            true
437        ));
438    }
439
440    #[test]
441    fn disabled_list_filters() {
442        let disabled = vec!["ctx_read".to_string()];
443        assert!(!is_tool_visible(
444            "ctx_read",
445            &ToolProfile::Power,
446            &disabled,
447            no_quirks(),
448            true
449        ));
450    }
451
452    #[test]
453    fn zed_hides_ctx_edit_only() {
454        let p = ToolProfile::Power;
455        let zed = ClientQuirks {
456            hide_ctx_edit: true,
457            hide_ctx_patch: false,
458        };
459        assert!(!is_tool_visible("ctx_edit", &p, &[], zed, true));
460        assert!(is_tool_visible("ctx_read", &p, &[], zed, true));
461    }
462
463    #[test]
464    fn native_editor_quirk_hides_ctx_patch_only() {
465        // #1008: a native-editor client in the lazy default drops ctx_patch —
466        // and nothing else.
467        let p = ToolProfile::Power;
468        let native = ClientQuirks {
469            hide_ctx_edit: false,
470            hide_ctx_patch: true,
471        };
472        assert!(!is_tool_visible("ctx_patch", &p, &[], native, true));
473        assert!(is_tool_visible("ctx_read", &p, &[], native, true));
474        assert!(is_tool_visible("ctx_edit", &p, &[], native, true));
475    }
476
477    #[test]
478    fn quirks_resolution_is_client_and_candidate_aware() {
479        // Native-editor clients skip ctx_patch in the lazy default…
480        for client in ["Cursor", "zed 0.164", "Windsurf", "antigravity", "opencode"] {
481            let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
482            assert!(q.hide_ctx_patch, "{client}: lazy core must hide ctx_patch");
483        }
484        // …clients without a reliable native editor get it (#637: Claude Code's
485        // read-before-write guard breaks under the read-redirect hook).
486        for client in ["claude-code", "CodeBuddy", "pi", "", "my-sdk-harness"] {
487            let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
488            assert!(
489                !q.hide_ctx_patch,
490                "{client:?}: lazy core must show ctx_patch"
491            );
492        }
493        // A pinned profile is client-agnostic — never hide ctx_patch there.
494        for candidate in [
495            CandidateSet::ProfileAuthoritative,
496            CandidateSet::Full,
497            CandidateSet::Unified,
498        ] {
499            let q = ClientQuirks::resolve("Cursor", candidate);
500            assert!(
501                !q.hide_ctx_patch,
502                "{candidate:?}: pinned/full surfaces are client-agnostic"
503            );
504        }
505        // The Zed ctx_edit quirk is independent of the candidate set.
506        assert!(ClientQuirks::resolve("zed", CandidateSet::Full).hide_ctx_edit);
507        assert!(!ClientQuirks::resolve("Cursor", CandidateSet::Full).hide_ctx_edit);
508    }
509
510    #[test]
511    fn role_block_hides_tool() {
512        assert!(!is_tool_visible(
513            "ctx_read",
514            &ToolProfile::Power,
515            &[],
516            no_quirks(),
517            false
518        ));
519    }
520
521    #[test]
522    fn category_gate_only_in_default_lean_mode() {
523        // Lazy gate applies only when the client supports list_changed AND no
524        // explicit profile is set.
525        assert!(category_gate_applies(true, false));
526        // Explicit profile is authoritative — never gated (#358).
527        assert!(!category_gate_applies(true, true));
528        // Static-list clients are never gated regardless of profile.
529        assert!(!category_gate_applies(false, false));
530        assert!(!category_gate_applies(false, true));
531    }
532
533    #[test]
534    fn invoker_added_when_missing_in_lazy_mode() {
535        assert!(needs_invoker(false, false, true, &[]));
536    }
537
538    #[test]
539    fn invoker_not_added_in_full_mode() {
540        assert!(!needs_invoker(true, false, true, &[]));
541    }
542
543    #[test]
544    fn invoker_not_duplicated_when_present() {
545        assert!(!needs_invoker(false, true, true, &[]));
546    }
547
548    #[test]
549    fn invoker_respects_role_and_disabled() {
550        assert!(!needs_invoker(false, false, false, &[]));
551        assert!(!needs_invoker(
552            false,
553            false,
554            true,
555            &["ctx_call".to_string()]
556        ));
557    }
558
559    /// #576 schema diet: the lazy-core surface is the default fixed cost every
560    /// session pays — keep it bounded. Per-tool cap keeps any single schema
561    /// from bloating; the total cap keeps the whole advertised surface lean.
562    /// (Raw registry defs, before description compression — worst case.)
563    ///
564    /// The total grew with the 14th core tool, `ctx_semantic_search` (#422):
565    /// it joined the lean core so agents discover semantic search by default
566    /// instead of never reaching for it. The per-tool cap (300) still guards
567    /// individual bloat; the total budget is sized to that 14-tool surface.
568    ///
569    /// Bumped to 2260 for #432: `ctx_read` now advertises the `offset`/`limit`
570    /// aliases (so agents trained on the native Read tool discover them), a
571    /// deliberate +~32 tok. Descriptions are kept terse to limit the cost.
572    ///
573    /// Bumped to 2275 for #451: `ctx_shell` now states it runs the system shell
574    /// profile-free (no rc/profile sourced), a deliberate +~13 tok so agents stop
575    /// mistaking it for a config-loaded interactive bash. Kept to one terse clause.
576    ///
577    /// Bumped to per-tool 335 / total 2310 for #513: `ctx_read` now documents the
578    /// verbatim escape hatch (`raw=true` arg + `raw` mode) so agents — especially
579    /// non-Opus models that fought the compression — discover how to get exact
580    /// bytes for review/audit instead of guessing. `ctx_read` is the richest core
581    /// tool and is the only one that crosses 300; the per-tool cap still guards
582    /// every other tool from bloat. Kept to terse clauses (+~33 tok on ctx_read).
583    ///
584    /// Bumped to per-tool 360 / total 2340 for #509: `ctx_read` absorbs the
585    /// `ctx_multi_read` batch capability via a `paths` array, so two tools collapse
586    /// into one (`ctx_smart_read` + `ctx_multi_read` are now deprecated aliases
587    /// hidden from the surface). The net effect REDUCES the advertised surface; the
588    /// only local cost is +~18 tok on `ctx_read`'s schema for the new `paths` arg.
589    ///
590    /// #509 search consolidation (cont.): `ctx_search` now subsumes semantic
591    /// search + symbol lookup via an `action` enum, so `ctx_semantic_search` left
592    /// the core set (it + `ctx_symbol` are deprecated aliases). `ctx_search` grew
593    /// (~196 → ~318 tok) but the core total DROPPED (~2298 → ~2150, one fewer
594    /// tool), so the budgets were left unchanged with comfortable headroom.
595    ///
596    /// #578 schema diet: redundant per-property descriptions dropped (names +
597    /// enums self-explain), teaching paragraphs tightened, and `ctx_callgraph`
598    /// (~147 tok) replaced `ctx_graph` (~300 tok) in the lazy core so the
599    /// advertised set matches the injected INTENT playbook. Measured ~1685 tok
600    /// → budgets lowered 360→300 per tool, 2340→1780 total. What remains is
601    /// functional teaching (ctx_read mode enum, ctx_search action routing,
602    /// compose-first) — cut below this only with A/B efficacy evidence.
603    ///
604    /// Bumped to 2050 total for #1008: `ctx_patch` (anchored editing, ~263 tok
605    /// after its schema diet) joined the lazy core so the injected "edit after
606    /// reading → ctx_patch" rule points at an advertised tool. This is the
607    /// worst case (no client quirks): clients with a reliable native editor
608    /// (Cursor, Zed, Windsurf, …) skip `ctx_patch` via `ClientQuirks` and stay
609    /// at the previous ~1685-tok surface.
610    ///
611    /// Bumped to 2060 for #870: `ctx_search` gained `exclude`/`exclude_pattern`
612    /// negative filters (+~7 tok on its schema).
613    ///
614    /// Bumped to 370/2500 for #871: `ctx_search` gained `queries` batch mode
615    /// and restored full action descriptions. Tool correctness > token savings —
616    /// incomplete descriptions cause agents to misuse parameters.
617    ///
618    /// Bumped to 410/3000 for #1020: `ctx_patch` gained per-op JSON Schema
619    /// if/then conditionals so required params are discoverable pre-call.
620    #[test]
621    fn core_tool_surface_stays_within_budget() {
622        const PER_TOOL_BUDGET: usize = 410;
623        const TOTAL_BUDGET: usize = 3000;
624
625        let _guard = crate::core::data_dir::isolated_data_dir();
626        let core = crate::tool_defs::core_tool_names();
627        let defs: Vec<_> = crate::server::registry::build_registry()
628            .tool_defs()
629            .into_iter()
630            .filter(|t| core.contains(&t.name.as_ref()))
631            .collect();
632        assert_eq!(defs.len(), core.len(), "every core tool must be registered");
633
634        let mut total = 0usize;
635        for t in &defs {
636            let desc = t.description.as_deref().unwrap_or("");
637            let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
638            let cost = crate::core::tokens::count_tokens(desc)
639                + crate::core::tokens::count_tokens(&schema);
640            eprintln!("{:24} {cost:4} tok", t.name.as_ref());
641            assert!(
642                cost <= PER_TOOL_BUDGET,
643                "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
644                t.name
645            );
646            total += cost;
647        }
648        eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
649        assert!(
650            total <= TOTAL_BUDGET,
651            "core surface costs {total} tok (budget {TOTAL_BUDGET})"
652        );
653    }
654
655    #[test]
656    fn resolve_auto_returns_non_auto_unchanged() {
657        assert_eq!(
658            resolve_auto_profile(&ToolProfile::Power),
659            ToolProfile::Power
660        );
661        assert_eq!(
662            resolve_auto_profile(&ToolProfile::Minimal),
663            ToolProfile::Minimal
664        );
665    }
666
667    #[test]
668    fn resolve_auto_resolves_to_concrete_profile() {
669        let resolved = resolve_auto_profile(&ToolProfile::Auto);
670        assert_ne!(resolved, ToolProfile::Auto);
671    }
672
673    // ── Shadow-Only surface tests ──────────────────────────────
674
675    #[test]
676    fn shadow_only_candidate_when_hook_covered() {
677        // hook_covered=true + default surface = ShadowOnly
678        let c = candidate_set(&CandidateInputs {
679            full_mode: false,
680            unified_env: false,
681            explicit_profile: false,
682            hook_covered: true,
683        });
684        assert_eq!(c, CandidateSet::ShadowOnly);
685    }
686
687    #[test]
688    fn shadow_only_overridden_by_full_mode() {
689        let c = candidate_set(&CandidateInputs {
690            full_mode: true,
691            unified_env: false,
692            explicit_profile: false,
693            hook_covered: true,
694        });
695        assert_eq!(
696            c,
697            CandidateSet::Full,
698            "LEAN_CTX_FULL_TOOLS=1 must override shadow-only"
699        );
700    }
701
702    #[test]
703    fn shadow_only_overridden_by_explicit_profile() {
704        let c = candidate_set(&CandidateInputs {
705            full_mode: false,
706            unified_env: false,
707            explicit_profile: true,
708            hook_covered: true,
709        });
710        assert_eq!(
711            c,
712            CandidateSet::ProfileAuthoritative,
713            "explicit profile must override shadow-only"
714        );
715    }
716
717    #[test]
718    fn lazy_core_when_not_hook_covered() {
719        let c = candidate_set(&CandidateInputs {
720            full_mode: false,
721            unified_env: false,
722            explicit_profile: false,
723            hook_covered: false,
724        });
725        assert_eq!(
726            c,
727            CandidateSet::LazyCore,
728            "non-hook client must get LazyCore"
729        );
730    }
731
732    #[test]
733    fn shadow_only_surface_stays_within_budget() {
734        const SHADOW_BUDGET: usize = 200;
735
736        let _guard = crate::core::data_dir::isolated_data_dir();
737        let defs: Vec<_> = crate::server::registry::build_registry()
738            .tool_defs()
739            .into_iter()
740            .filter(|t| t.name.as_ref() == INVOKER)
741            .collect();
742        assert_eq!(
743            defs.len(),
744            1,
745            "shadow-only pool must contain exactly ctx_call"
746        );
747        assert_eq!(defs[0].name.as_ref(), "ctx_call");
748
749        let desc = defs[0].description.as_deref().unwrap_or("");
750        let schema = serde_json::to_string(&defs[0].input_schema).unwrap_or_default();
751        let cost =
752            crate::core::tokens::count_tokens(desc) + crate::core::tokens::count_tokens(&schema);
753        eprintln!("SHADOW-ONLY: ctx_call = {cost} tok (budget {SHADOW_BUDGET})");
754        assert!(
755            cost <= SHADOW_BUDGET,
756            "ctx_call costs {cost} tok (shadow budget {SHADOW_BUDGET})"
757        );
758    }
759}