Skip to main content

lean_ctx/core/
tool_visibility.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure tool-visibility policy for the MCP `tools/list` response.
3//!
4//! Extracted from the (async, server-bound) `list_tools` handler so the policy
5//! is unit-testable in isolation. The handler resolves the candidate set
6//! (lazy-core vs profile-authoritative vs full registry) and the per-call gates
7//! (role, workflow), then defers to these helpers for the stable rules:
8//!   * Internal/meta tools are never advertised.
9//!   * The active profile, `disabled_tools`, and the per-client
10//!     [`ClientQuirks`] (Zed `ctx_edit`, native-editor `ctx_patch`) filter the
11//!     candidates.
12//!   * The universal invoker (`ctx_call`) is force-advertised in non-full mode so
13//!     tools hidden by lazy/profile filtering stay reachable.
14
15use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
16
17use super::dynamic_tools::{ToolCategory, categorize_tool};
18use crate::core::tool_profiles::ToolProfile;
19
20// ── Auto-profile session signals ─────────────────────────────
21
22static AUTO_TURN_COUNT: AtomicU64 = AtomicU64::new(0);
23static AUTO_CTX_TOOLS_USED: AtomicBool = AtomicBool::new(false);
24static AUTO_SYSTEM_PROMPT_TOKENS: AtomicUsize = AtomicUsize::new(0);
25
26/// Increment the Auto-profile turn counter (call once per tools/list request).
27pub fn record_auto_turn() {
28    AUTO_TURN_COUNT.fetch_add(1, Ordering::Relaxed);
29}
30
31/// Mark that the agent has invoked a ctx_* MCP tool in this session.
32pub fn mark_auto_ctx_tool_used() {
33    AUTO_CTX_TOOLS_USED.store(true, Ordering::Relaxed);
34}
35
36/// Resolve `ToolProfile::Auto` to a concrete profile using session signals.
37/// Non-Auto profiles are returned as-is.
38#[must_use]
39pub fn resolve_auto_profile(profile: &ToolProfile) -> ToolProfile {
40    if *profile != ToolProfile::Auto {
41        return profile.clone();
42    }
43    ToolProfile::resolve_auto(
44        AUTO_TURN_COUNT.load(Ordering::Relaxed),
45        AUTO_CTX_TOOLS_USED.load(Ordering::Relaxed),
46        AUTO_SYSTEM_PROMPT_TOKENS.load(Ordering::Relaxed),
47    )
48}
49
50/// The universal invoker tool name. A static-list MCP client can call any
51/// registered tool through it, even when that tool isn't advertised.
52pub const INVOKER: &str = "ctx_call";
53
54/// Which candidate pool `tools/list` starts from, before per-tool gates run.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CandidateSet {
57    /// Full registry (`LEAN_CTX_FULL_TOOLS=1` / `LEAN_CTX_LAZY_TOOLS=0`).
58    Full,
59    /// Consolidated unified surface (`LEAN_CTX_UNIFIED`).
60    Unified,
61    /// The user pinned a profile — it is authoritative and resolves against
62    /// the full registry (#358), so `standard` advertises its complete set.
63    ProfileAuthoritative,
64    /// Lean default: only `CORE_TOOL_NAMES` are advertised; everything else
65    /// stays reachable through [`INVOKER`] (#575).
66    LazyCore,
67    /// Deprecated (GH #1474): was the hook-covered-only surface (just ctx_call).
68    /// Never returned by `candidate_set` anymore — kept only for exhaustive
69    /// match compatibility until fully removed.
70    #[allow(dead_code)]
71    ShadowOnly,
72}
73
74/// Inputs to [`candidate_set`]. Avoids a long bool parameter list (#clippy::fn_params_excessive_bools).
75pub struct CandidateInputs {
76    pub full_mode: bool,
77    pub unified_env: bool,
78    pub explicit_profile: bool,
79    pub hook_covered: bool,
80}
81
82/// Decides the candidate pool. Single source of truth for the `tools/list`
83/// handler AND offline measurement (`doctor overhead`), so the advertised
84/// surface and the reported overhead can never drift apart.
85#[must_use]
86pub fn candidate_set(inp: &CandidateInputs) -> CandidateSet {
87    if inp.full_mode {
88        CandidateSet::Full
89    } else if inp.unified_env {
90        CandidateSet::Unified
91    } else if inp.explicit_profile {
92        CandidateSet::ProfileAuthoritative
93    } else {
94        // GH #1474: all MCP-connected clients (hook-covered or not) get the
95        // lazy-core surface. Shadow-only (ctx_call alone) caused agent confusion
96        // because they could not discover ctx_read/ctx_search/etc. by name.
97        CandidateSet::LazyCore
98    }
99}
100
101/// Whether the shadow-only tool surface is enabled (config or env).
102#[allow(dead_code)]
103fn is_shadow_surface_enabled() -> bool {
104    if let Ok(v) = std::env::var("LEAN_CTX_TOOL_SURFACE") {
105        return v.eq_ignore_ascii_case("shadow") || v.eq_ignore_ascii_case("auto");
106    }
107    let cfg = crate::core::config::Config::load();
108    // Only "mcp" explicitly disables shadow surface; "auto", "shadow", and
109    // unset all enable it (when the caller already confirmed hook_covered).
110    !matches!(cfg.tool_surface.as_deref(), Some("mcp"))
111}
112
113/// Whether the user explicitly pinned a tool profile (config key, custom tool
114/// list, or env var) — the trigger for [`CandidateSet::ProfileAuthoritative`].
115#[must_use]
116pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
117    cfg.tool_profile.is_some()
118        || !cfg.tools_enabled.is_empty()
119        || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
120}
121
122/// Client-specific advertising quirks, resolved once per `tools/list` from the
123/// MCP `clientInfo` name and the candidate set.
124///
125/// [`ClientQuirks::default`] (no quirks) is the "default client" used by
126/// offline measurement — the worst-case surface, nothing hidden.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub struct ClientQuirks {
129    /// Zed cannot handle `ctx_edit` (schema quirk) — hide it there.
130    pub hide_ctx_edit: bool,
131    /// Lazy-core only (#1008): the client ships a reliable native str-replace
132    /// editor, so the *default* surface skips `ctx_patch` — those sessions pay
133    /// zero extra schema tokens. A pinned profile is the user's explicit,
134    /// client-agnostic choice and always advertises its full set.
135    pub hide_ctx_patch: bool,
136}
137
138impl ClientQuirks {
139    /// Resolve the quirks for one `tools/list` answer.
140    #[must_use]
141    pub fn resolve(client_name: &str, candidate: CandidateSet) -> Self {
142        let lower = client_name.to_lowercase();
143        Self {
144            hide_ctx_edit: lower.contains("zed"),
145            hide_ctx_patch: candidate == CandidateSet::LazyCore && has_native_editor(&lower),
146        }
147    }
148}
149
150/// Clients whose built-in edit tool is reliable enough that the default
151/// (lazy-core) surface need not advertise `ctx_patch`: Cursor, Zed,
152/// Windsurf/Codeium, Antigravity, OpenCode. Everyone else gets the anchored
153/// editor — Claude Code (the hook read-redirect breaks its native
154/// read-before-write guard, #637), CodeBuddy, pi/SDK harnesses and
155/// unknown/headless clients that have no native editor at all.
156fn has_native_editor(lower_client_name: &str) -> bool {
157    [
158        "cursor",
159        "zed",
160        "windsurf",
161        "codeium",
162        "antigravity",
163        "opencode",
164    ]
165    .iter()
166    .any(|c| lower_client_name.contains(c))
167}
168
169/// Decides whether a tool name should appear in `tools/list`.
170///
171/// `role_allows` is supplied by the caller (it depends on the active role, which
172/// is resolved outside this pure function). Internal tools are hidden
173/// unconditionally — they're invoked automatically or via [`INVOKER`].
174#[must_use]
175pub fn is_tool_visible(
176    name: &str,
177    profile: &ToolProfile,
178    disabled: &[String],
179    quirks: ClientQuirks,
180    role_allows: bool,
181) -> bool {
182    if categorize_tool(name) == ToolCategory::Internal {
183        return false;
184    }
185    // Deprecated aliases and the local-only collaboration substrate are hidden
186    // from every advertised surface but remain registered and directly callable.
187    if !super::dynamic_tools::is_publicly_advertised_tool(name) {
188        return false;
189    }
190    if !profile.is_tool_enabled(name) {
191        return false;
192    }
193    if disabled.iter().any(|d| d == name) {
194        return false;
195    }
196    if quirks.hide_ctx_edit && name == "ctx_edit" {
197        return false;
198    }
199    if quirks.hide_ctx_patch && name == "ctx_patch" {
200        return false;
201    }
202    role_allows
203}
204
205/// The tool set agent-facing guidance may name for `client_name`: the MCP
206/// `instructions` and the injected rule files (#1849). It passes the same
207/// gates as `tools/list` — the effective profile, `disabled_tools` (which
208/// folds in `prefer_native_editor`) and the client's quirks — so the text
209/// never points the agent at a tool its session cannot call.
210#[must_use]
211pub fn guidance_profile(cfg: &crate::core::config::Config, client_name: &str) -> ToolProfile {
212    let candidate = candidate_set(&CandidateInputs {
213        full_mode: crate::tool_defs::is_full_mode(),
214        unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
215        explicit_profile: explicit_profile(cfg),
216        hook_covered: false, // no longer affects the candidate set (GH #1474)
217    });
218    narrow_to_surface(
219        lazy_core_surface(cfg.tool_profile_effective(), candidate),
220        &cfg.disabled_tools_effective(),
221        ClientQuirks::resolve(client_name, candidate),
222    )
223}
224
225/// The unpinned default resolves to `Power`, but `tools/list` advertises only
226/// `CORE_TOOL_NAMES` there; the rest is reached through `ctx_call`. Guidance
227/// written for `Power` named tools such as `ctx_knowledge` that OpenCode and
228/// other lazy-core clients never saw, and agents called them directly.
229fn lazy_core_surface(profile: ToolProfile, candidate: CandidateSet) -> ToolProfile {
230    if candidate != CandidateSet::LazyCore {
231        return profile;
232    }
233    ToolProfile::Custom(
234        crate::tool_defs::CORE_TOOL_NAMES
235            .iter()
236            .filter(|name| profile.is_tool_enabled(name))
237            .map(|name| (*name).to_string())
238            .collect(),
239    )
240}
241
242/// Pure core of [`guidance_profile`]: `profile` minus every tool the surface
243/// withholds. `Auto` is taken at its starting point (minimal) — guidance is
244/// written once per session and must not promise a tool before escalation.
245#[must_use]
246pub fn narrow_to_surface(
247    profile: ToolProfile,
248    disabled: &[String],
249    quirks: ClientQuirks,
250) -> ToolProfile {
251    let profile = if profile == ToolProfile::Auto {
252        ToolProfile::Minimal
253    } else {
254        profile
255    };
256    let mut hidden: Vec<&str> = disabled.iter().map(String::as_str).collect();
257    if quirks.hide_ctx_edit {
258        hidden.push("ctx_edit");
259    }
260    if quirks.hide_ctx_patch {
261        hidden.push("ctx_patch");
262    }
263    hidden
264        .into_iter()
265        .fold(profile, |p, name| p.without_tool(name))
266}
267
268/// Computes the tool set this install advertises to a default client
269/// (no client quirks, no role restriction, no workflow gate, static tool list),
270/// including the live description compression. Offline counterpart of the
271/// `tools/list` handler for `doctor overhead` / `ContextOverhead::measure` —
272/// kept next to the pure gates so measurement cannot drift from policy.
273/// "No quirks" is the worst case: a client without a native editor sees
274/// `ctx_patch` too, so the reported overhead never understates.
275#[must_use]
276pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
277    let cfg = crate::core::config::Config::load();
278    let disabled = cfg.disabled_tools_effective();
279    let profile = cfg.tool_profile_effective();
280    let full_mode = crate::tool_defs::is_full_mode();
281    let catalog = crate::core::tool_catalog::tool_defs();
282
283    let candidate = candidate_set(&CandidateInputs {
284        full_mode,
285        unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
286        explicit_profile: explicit_profile(&cfg),
287        hook_covered: false, // offline measurement uses worst-case (no hook coverage)
288    });
289    let pool: Vec<rmcp::model::Tool> = match candidate {
290        CandidateSet::Full | CandidateSet::ProfileAuthoritative => catalog.clone(),
291        CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
292        // GH #1474: ShadowOnly no longer returned; treat as LazyCore.
293        CandidateSet::ShadowOnly | CandidateSet::LazyCore => {
294            let core = crate::tool_defs::core_tool_names();
295            catalog
296                .iter()
297                .filter(|t| core.contains(&t.name.as_ref()))
298                .cloned()
299                .collect()
300        }
301    };
302
303    let mut tools: Vec<_> = pool
304        .into_iter()
305        .filter(|t| {
306            is_tool_visible(
307                t.name.as_ref(),
308                &profile,
309                &disabled,
310                ClientQuirks::default(),
311                true,
312            )
313        })
314        .collect();
315
316    let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
317    if needs_invoker(full_mode, already, true, &disabled)
318        && let Some(def) = catalog.into_iter().find(|t| t.name.as_ref() == INVOKER)
319    {
320        tools.push(def);
321    }
322
323    let level = crate::core::config::CompressionLevel::effective(&cfg);
324    let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
325    if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
326        return tools;
327    }
328    tools
329        .into_iter()
330        .map(|mut t| {
331            let compressed = crate::core::terse::mcp_compress::compress_description(
332                t.name.as_ref(),
333                t.description.as_deref().unwrap_or(""),
334                mode,
335            );
336            t.description = Some(compressed.into());
337            t
338        })
339        .collect()
340}
341
342/// Whether the lazy per-category gate should filter the advertised tool set.
343///
344/// The dynamic-tools category gate (load tools on demand, signalled via
345/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
346/// surface small for capable clients. An explicit profile is the user's chosen,
347/// authoritative surface, so it must be advertised in full — otherwise category
348/// gating silently drops profile-enabled tools (e.g. Standard's
349/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
350/// advertised set stops matching `lean-ctx tools show` (#358).
351#[must_use]
352pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
353    supports_list_changed && !explicit_profile
354}
355
356/// Whether [`INVOKER`] must be force-added to the advertised set.
357///
358/// True only in non-full mode when it isn't already present, the role permits
359/// it, and it isn't explicitly disabled. In full mode every tool is already
360/// listed, so no gateway is needed.
361#[must_use]
362pub fn needs_invoker(
363    full_mode: bool,
364    already_present: bool,
365    invoker_role_allowed: bool,
366    disabled: &[String],
367) -> bool {
368    !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    /// No client quirks — the default/measurement client.
376    fn no_quirks() -> ClientQuirks {
377        ClientQuirks::default()
378    }
379
380    #[test]
381    fn internal_tools_never_visible_even_in_power() {
382        // Power enables everything, but Internal/meta tools must still be hidden.
383        let p = ToolProfile::Power;
384        assert!(!is_tool_visible("ctx_metrics", &p, &[], no_quirks(), true));
385        assert!(!is_tool_visible("ctx_cost", &p, &[], no_quirks(), true));
386        assert!(!is_tool_visible(
387            "ctx_discover_tools",
388            &p,
389            &[],
390            no_quirks(),
391            true
392        ));
393    }
394
395    #[test]
396    fn core_tool_visible_under_power() {
397        assert!(is_tool_visible(
398            "ctx_read",
399            &ToolProfile::Power,
400            &[],
401            no_quirks(),
402            true
403        ));
404    }
405
406    #[test]
407    fn standard_exposes_its_advertised_tools() {
408        // These are in STANDARD_TOOLS but were dropped by the old
409        // `core ∩ standard` intersection. Profile-authoritative resolution must
410        // surface them.
411        let p = ToolProfile::Standard;
412        assert!(is_tool_visible("ctx_execute", &p, &[], no_quirks(), true));
413        assert!(is_tool_visible("ctx_compose", &p, &[], no_quirks(), true));
414        assert!(is_tool_visible("ctx_callgraph", &p, &[], no_quirks(), true));
415        assert!(is_tool_visible("ctx_graph", &p, &[], no_quirks(), true));
416        // #1008: anchored editing ships with the pinned Standard profile.
417        assert!(is_tool_visible("ctx_patch", &p, &[], no_quirks(), true));
418    }
419
420    #[test]
421    fn minimal_hides_non_minimal_tools() {
422        let p = ToolProfile::Minimal;
423        assert!(is_tool_visible("ctx_read", &p, &[], no_quirks(), true));
424        assert!(!is_tool_visible("ctx_index", &p, &[], no_quirks(), true));
425    }
426
427    #[test]
428    fn disabled_list_filters() {
429        let disabled = vec!["ctx_read".to_string()];
430        assert!(!is_tool_visible(
431            "ctx_read",
432            &ToolProfile::Power,
433            &disabled,
434            no_quirks(),
435            true
436        ));
437    }
438
439    #[test]
440    fn zed_hides_ctx_edit_only() {
441        let p = ToolProfile::Power;
442        let zed = ClientQuirks {
443            hide_ctx_edit: true,
444            hide_ctx_patch: false,
445        };
446        assert!(!is_tool_visible("ctx_edit", &p, &[], zed, true));
447        assert!(is_tool_visible("ctx_read", &p, &[], zed, true));
448    }
449
450    #[test]
451    fn native_editor_quirk_hides_ctx_patch_only() {
452        // #1008: a native-editor client in the lazy default drops ctx_patch —
453        // and nothing else.
454        let p = ToolProfile::Power;
455        let native = ClientQuirks {
456            hide_ctx_edit: false,
457            hide_ctx_patch: true,
458        };
459        assert!(!is_tool_visible("ctx_patch", &p, &[], native, true));
460        assert!(is_tool_visible("ctx_read", &p, &[], native, true));
461        assert!(is_tool_visible("ctx_edit", &p, &[], native, true));
462    }
463
464    /// Every tool the guidance names under `profile`, checked against the
465    /// same gate `tools/list` applies (#1849).
466    fn guidance_names(profile: &ToolProfile) -> String {
467        use crate::core::config::CompressionLevel;
468        use crate::core::rules_canonical::{Wrapper, render};
469        let mut text = String::new();
470        for shadow in [true, false] {
471            text.push_str(&render(
472                shadow,
473                Wrapper::Bare,
474                CompressionLevel::Off,
475                profile,
476            ));
477        }
478        text
479    }
480
481    #[test]
482    fn disabled_tools_leave_the_guidance() {
483        // #1849: `disabled_tools = ["ctx_callgraph"]` hid the tool from
484        // `tools/list`, but the instructions still said "ctx_callgraph (callers)".
485        let disabled = vec!["ctx_callgraph".to_string()];
486        let p = narrow_to_surface(ToolProfile::Power, &disabled, ClientQuirks::default());
487        assert!(!p.is_tool_enabled("ctx_callgraph"));
488        assert!(
489            !is_tool_visible(
490                "ctx_callgraph",
491                &ToolProfile::Power,
492                &disabled,
493                ClientQuirks::default(),
494                true
495            ),
496            "precondition: tools/list hides it"
497        );
498        let text = guidance_names(&p);
499        assert!(
500            !text.contains("ctx_callgraph"),
501            "guidance names a disabled tool:\n{text}"
502        );
503        // Nothing else is lost on the way.
504        assert!(text.contains("ctx_patch"));
505        assert!(text.contains("ctx_compose"));
506    }
507
508    #[test]
509    fn lazy_core_guidance_names_only_core_tools() {
510        // The unpinned default is `Power`, yet a lazy-core session (OpenCode,
511        // Cursor, …) is offered only CORE_TOOL_NAMES. Its rules named
512        // ctx_knowledge, which the agent then called and could not find.
513        let core = lazy_core_surface(ToolProfile::Power, CandidateSet::LazyCore);
514        let native = ClientQuirks {
515            hide_ctx_edit: false,
516            hide_ctx_patch: true,
517        };
518        let surface = narrow_to_surface(core, &[], native);
519        let text = guidance_names(&surface);
520        for absent in ["ctx_knowledge", "ctx_patch", "ctx_graph", "ctx_overview"] {
521            assert!(!text.contains(absent), "names {absent}:\n{text}");
522        }
523        for present in ["ctx_compose", "ctx_session", "ctx_callgraph", "ctx_call("] {
524            assert!(text.contains(present), "misses {present}:\n{text}");
525        }
526        // A pinned profile is authoritative and keeps its full set.
527        assert_eq!(
528            lazy_core_surface(ToolProfile::Power, CandidateSet::ProfileAuthoritative),
529            ToolProfile::Power
530        );
531    }
532
533    #[test]
534    fn a_hidden_ctx_patch_leaves_the_guidance() {
535        // #1849: `prefer_native_editor` folds ctx_patch into the disabled
536        // list; the native-editor quirk hides it on the lazy surface. Either
537        // way "if denied, use ctx_patch" points at a tool the agent lacks.
538        let quirk = ClientQuirks {
539            hide_ctx_edit: false,
540            hide_ctx_patch: true,
541        };
542        let by_quirk = narrow_to_surface(ToolProfile::Power, &[], quirk);
543        let disabled = vec!["ctx_edit".to_string(), "ctx_patch".to_string()];
544        let by_config =
545            narrow_to_surface(ToolProfile::Standard, &disabled, ClientQuirks::default());
546        for p in [by_quirk, by_config] {
547            let text = guidance_names(&p);
548            assert!(
549                !text.contains("ctx_patch"),
550                "{p}: guidance names ctx_patch:\n{text}"
551            );
552            assert!(
553                text.contains("the host's native edit tool"),
554                "{p}: edit line lost"
555            );
556        }
557    }
558
559    #[test]
560    fn auto_guidance_starts_at_minimal() {
561        // Guidance is written once per session; Auto must not promise the
562        // tools it only escalates to later.
563        let p = narrow_to_surface(ToolProfile::Auto, &[], ClientQuirks::default());
564        assert_eq!(p, ToolProfile::Minimal);
565        assert!(!guidance_names(&p).contains("ctx_compose"));
566    }
567
568    #[test]
569    fn an_unrestricted_surface_keeps_the_profile() {
570        for p in [
571            ToolProfile::Power,
572            ToolProfile::Standard,
573            ToolProfile::Minimal,
574        ] {
575            assert_eq!(
576                narrow_to_surface(p.clone(), &[], ClientQuirks::default()),
577                p
578            );
579        }
580    }
581
582    #[test]
583    fn quirks_resolution_is_client_and_candidate_aware() {
584        // Native-editor clients skip ctx_patch in the lazy default…
585        for client in ["Cursor", "zed 0.164", "Windsurf", "antigravity", "opencode"] {
586            let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
587            assert!(q.hide_ctx_patch, "{client}: lazy core must hide ctx_patch");
588        }
589        // …clients without a reliable native editor get it (#637: Claude Code's
590        // read-before-write guard breaks under the read-redirect hook).
591        for client in ["claude-code", "CodeBuddy", "pi", "", "my-sdk-harness"] {
592            let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
593            assert!(
594                !q.hide_ctx_patch,
595                "{client:?}: lazy core must show ctx_patch"
596            );
597        }
598        // A pinned profile is client-agnostic — never hide ctx_patch there.
599        for candidate in [
600            CandidateSet::ProfileAuthoritative,
601            CandidateSet::Full,
602            CandidateSet::Unified,
603        ] {
604            let q = ClientQuirks::resolve("Cursor", candidate);
605            assert!(
606                !q.hide_ctx_patch,
607                "{candidate:?}: pinned/full surfaces are client-agnostic"
608            );
609        }
610        // The Zed ctx_edit quirk is independent of the candidate set.
611        assert!(ClientQuirks::resolve("zed", CandidateSet::Full).hide_ctx_edit);
612        assert!(!ClientQuirks::resolve("Cursor", CandidateSet::Full).hide_ctx_edit);
613    }
614
615    #[test]
616    fn role_block_hides_tool() {
617        assert!(!is_tool_visible(
618            "ctx_read",
619            &ToolProfile::Power,
620            &[],
621            no_quirks(),
622            false
623        ));
624    }
625
626    #[test]
627    fn category_gate_only_in_default_lean_mode() {
628        // Lazy gate applies only when the client supports list_changed AND no
629        // explicit profile is set.
630        assert!(category_gate_applies(true, false));
631        // Explicit profile is authoritative — never gated (#358).
632        assert!(!category_gate_applies(true, true));
633        // Static-list clients are never gated regardless of profile.
634        assert!(!category_gate_applies(false, false));
635        assert!(!category_gate_applies(false, true));
636    }
637
638    #[test]
639    fn invoker_added_when_missing_in_lazy_mode() {
640        assert!(needs_invoker(false, false, true, &[]));
641    }
642
643    #[test]
644    fn invoker_not_added_in_full_mode() {
645        assert!(!needs_invoker(true, false, true, &[]));
646    }
647
648    #[test]
649    fn invoker_not_duplicated_when_present() {
650        assert!(!needs_invoker(false, true, true, &[]));
651    }
652
653    #[test]
654    fn invoker_respects_role_and_disabled() {
655        assert!(!needs_invoker(false, false, false, &[]));
656        assert!(!needs_invoker(
657            false,
658            false,
659            true,
660            &["ctx_call".to_string()]
661        ));
662    }
663
664    #[test]
665    fn resolve_auto_returns_non_auto_unchanged() {
666        assert_eq!(
667            resolve_auto_profile(&ToolProfile::Power),
668            ToolProfile::Power
669        );
670        assert_eq!(
671            resolve_auto_profile(&ToolProfile::Minimal),
672            ToolProfile::Minimal
673        );
674    }
675
676    #[test]
677    fn resolve_auto_resolves_to_concrete_profile() {
678        let resolved = resolve_auto_profile(&ToolProfile::Auto);
679        assert_ne!(resolved, ToolProfile::Auto);
680    }
681
682    // ── Hook-covered clients get LazyCore (#1474) ──────────────
683
684    #[test]
685    fn hook_covered_gets_lazy_core_not_shadow_only() {
686        // GH #1474: hook_covered=true no longer restricts to ShadowOnly
687        let c = candidate_set(&CandidateInputs {
688            full_mode: false,
689            unified_env: false,
690            explicit_profile: false,
691            hook_covered: true,
692        });
693        assert_eq!(c, CandidateSet::LazyCore);
694    }
695
696    #[test]
697    fn shadow_only_overridden_by_full_mode() {
698        let c = candidate_set(&CandidateInputs {
699            full_mode: true,
700            unified_env: false,
701            explicit_profile: false,
702            hook_covered: true,
703        });
704        assert_eq!(
705            c,
706            CandidateSet::Full,
707            "LEAN_CTX_FULL_TOOLS=1 must override shadow-only"
708        );
709    }
710
711    #[test]
712    fn shadow_only_overridden_by_explicit_profile() {
713        let c = candidate_set(&CandidateInputs {
714            full_mode: false,
715            unified_env: false,
716            explicit_profile: true,
717            hook_covered: true,
718        });
719        assert_eq!(
720            c,
721            CandidateSet::ProfileAuthoritative,
722            "explicit profile must override shadow-only"
723        );
724    }
725
726    #[test]
727    fn lazy_core_when_not_hook_covered() {
728        let c = candidate_set(&CandidateInputs {
729            full_mode: false,
730            unified_env: false,
731            explicit_profile: false,
732            hook_covered: false,
733        });
734        assert_eq!(
735            c,
736            CandidateSet::LazyCore,
737            "non-hook client must get LazyCore"
738        );
739    }
740}