Skip to main content

bamboo_skills/
capability_discovery.rs

1//! Deterministic, provider-neutral discovery over capability metadata.
2//!
3//! This module owns no provider wire format and no active capability state. It
4//! projects the canonical tool registry and immutable Skill/Workflow catalogs
5//! into a small metadata-only index, then ranks bounded queries without I/O.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use bamboo_domain::{
10    canonical_tool_name as canonical_registry_tool_name, resolve_tool_reference_name,
11    CapabilityInvocationPolicy, CapabilityInvocationTarget, CapabilityKind, CapabilityLoadingClass,
12    CapabilityMatch, CapabilitySource, CapabilityStatus, ClassifiedToolIdentity,
13    ClassifiedToolSchema, DiscoverCapabilitiesRequest, DiscoverCapabilitiesResult, ToolSchema,
14    MAX_DISCOVERY_QUERY_CHARS, MAX_DISCOVERY_RESULTS,
15};
16use thiserror::Error;
17
18use crate::{
19    WorkflowCatalogEntry, WorkflowCatalogSnapshot, WorkflowKind, WorkflowSource, WorkflowStatus,
20};
21
22/// Discovery summaries are bounded independently of the source description so
23/// one verbose registry entry cannot dominate a bounded result.
24pub const MAX_CAPABILITY_SUMMARY_CHARS: usize = 240;
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub enum InvocationEligibility {
28    /// A capability is eligible when either catalog invocation mode is enabled.
29    #[default]
30    Any,
31    Explicit,
32    Automatic,
33}
34
35impl InvocationEligibility {
36    fn permits(self, policy: CapabilityInvocationPolicy) -> bool {
37        match self {
38            Self::Any => policy.explicit || policy.automatic,
39            Self::Explicit => policy.explicit,
40            Self::Automatic => policy.automatic,
41        }
42    }
43}
44
45/// Host-resolved eligibility for one immutable discovery projection.
46///
47/// Surface selection and authorization remain host responsibilities. Passing
48/// only surface-eligible tool metadata plus these filters prevents discovery
49/// from widening what a session can load.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CapabilityDiscoveryEligibility {
52    pub disabled_tool_names: BTreeSet<String>,
53    /// `None` means the caller already supplied a surface-scoped registry.
54    /// `Some(empty)` exposes no tools. References resolve exact-first against
55    /// the projected catalog, then through legacy/builtin aliases.
56    pub allowed_tool_names: Option<BTreeSet<String>>,
57    pub disabled_skill_ids: BTreeSet<String>,
58    /// `None` means no additional allowlist. `Some(empty)` exposes no Skills.
59    pub allowed_skill_ids: Option<BTreeSet<String>>,
60    pub skill_gateway_available: bool,
61    pub workflow_gateway_available: bool,
62    pub skill_invocation: InvocationEligibility,
63    pub workflow_invocation: InvocationEligibility,
64}
65
66impl Default for CapabilityDiscoveryEligibility {
67    fn default() -> Self {
68        Self {
69            disabled_tool_names: BTreeSet::new(),
70            allowed_tool_names: None,
71            disabled_skill_ids: BTreeSet::new(),
72            allowed_skill_ids: None,
73            skill_gateway_available: true,
74            workflow_gateway_available: true,
75            skill_invocation: InvocationEligibility::Any,
76            workflow_invocation: InvocationEligibility::Any,
77        }
78    }
79}
80
81/// Parameter-schema-free tool registry projection consumed by discovery.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ToolCapabilityMetadata {
84    pub canonical_name: String,
85    pub summary: String,
86    pub source: CapabilitySource,
87    pub aliases: Vec<String>,
88    pub available: bool,
89}
90
91/// Copy only searchable public metadata out of tool schemas. Parameter schemas
92/// are deliberately never cloned, serialized, or retained by discovery.
93pub fn project_tool_capability_metadata(schemas: &[ToolSchema]) -> Vec<ToolCapabilityMetadata> {
94    schemas
95        .iter()
96        .filter_map(|schema| {
97            let identity = ClassifiedToolIdentity::from_schema_name(&schema.function.name)?;
98            if identity.loading_class() == CapabilityLoadingClass::HostOnly {
99                return None;
100            }
101            Some(ToolCapabilityMetadata {
102                source: source_for_tool(identity.execution_name()),
103                aliases: aliases_for_tool(identity.execution_name()),
104                canonical_name: identity.execution_name().to_string(),
105                summary: bounded_summary(&schema.function.description),
106                available: true,
107            })
108        })
109        .collect()
110}
111
112/// Project a classified logical catalog into metadata-only discovery entries.
113/// HostOnly schemas never enter the searchable index, while Core and Deferred
114/// share the exact execution identity and policy used by provider projection.
115pub fn project_classified_tool_capability_metadata(
116    catalog: &[ClassifiedToolSchema],
117) -> Vec<ToolCapabilityMetadata> {
118    catalog
119        .iter()
120        .filter(|entry| entry.loading_class() != CapabilityLoadingClass::HostOnly)
121        .map(|entry| ToolCapabilityMetadata {
122            source: source_for_tool(entry.execution_name()),
123            aliases: aliases_for_tool(entry.execution_name()),
124            canonical_name: entry.execution_name().to_string(),
125            summary: bounded_summary(&entry.schema().function.description),
126            available: true,
127        })
128        .collect()
129}
130
131#[derive(Debug, Clone)]
132struct IndexedCapability {
133    value: CapabilityMatch,
134    normalized_names: Vec<String>,
135    normalized_summary: String,
136}
137
138/// Explanation of the existing automatic-selection policy, without query text.
139#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum AutomaticSkillOutcome {
142    EmptyQuery,
143    NoMatch,
144    BelowThreshold,
145    Ambiguous,
146    ExactMatch,
147    StrongMatch,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
151pub struct SkillMatchEvidence {
152    pub capability_ref: String,
153    pub source: CapabilitySource,
154    pub revision: Option<u64>,
155    pub exact_identity: bool,
156    pub name_token_hits: usize,
157    pub summary_token_hits: usize,
158    pub strong_match: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
162pub struct AutomaticSkillDecision {
163    pub query_sha256: String,
164    pub outcome: AutomaticSkillOutcome,
165    pub candidates: Vec<SkillMatchEvidence>,
166    pub selected: Option<CapabilityMatch>,
167}
168
169/// Immutable searchable projection. Constructing and querying it performs no
170/// filesystem, network, model, execution, authorization, or persistence work.
171#[derive(Debug, Clone, Default)]
172pub struct CapabilityDiscoveryIndex {
173    candidates: Vec<IndexedCapability>,
174}
175
176impl CapabilityDiscoveryIndex {
177    /// Build discovery from the exact classified catalog already resolved for
178    /// the current session/round, while capturing both command catalogs under
179    /// the store's publication read lock. Tool allow/disable references are not
180    /// applied twice; Skill/Workflow eligibility is still resolved here.
181    pub async fn from_resolved_classified_store(
182        tool_catalog: &[ClassifiedToolSchema],
183        store: &crate::SkillStore,
184        eligibility: &CapabilityDiscoveryEligibility,
185    ) -> Self {
186        let (skill_catalog, workflow_catalog) = store.command_catalog_snapshots().await;
187        let mut non_tool_eligibility = eligibility.clone();
188        non_tool_eligibility.disabled_tool_names.clear();
189        non_tool_eligibility.allowed_tool_names = None;
190        Self::from_snapshots(
191            project_classified_tool_capability_metadata(tool_catalog),
192            &skill_catalog,
193            &workflow_catalog,
194            &non_tool_eligibility,
195        )
196    }
197
198    /// Legacy compatibility facade for callers that do not yet have a
199    /// session-resolved classified catalog. New agent-loop code must use
200    /// [`Self::from_resolved_classified_store`] so goal/Skill/disabled eligibility
201    /// and provider projection cannot drift from discovery.
202    pub async fn from_store(
203        tool_schemas: &[ToolSchema],
204        store: &crate::SkillStore,
205        eligibility: &CapabilityDiscoveryEligibility,
206    ) -> Self {
207        let (skill_catalog, workflow_catalog) = store.command_catalog_snapshots().await;
208        Self::from_snapshots(
209            project_tool_capability_metadata(tool_schemas),
210            &skill_catalog,
211            &workflow_catalog,
212            eligibility,
213        )
214    }
215
216    pub fn from_snapshots(
217        tools: impl IntoIterator<Item = ToolCapabilityMetadata>,
218        skill_catalog: &WorkflowCatalogSnapshot,
219        workflow_catalog: &WorkflowCatalogSnapshot,
220        eligibility: &CapabilityDiscoveryEligibility,
221    ) -> Self {
222        let mut projected_tools = BTreeMap::<String, ToolCapabilityMetadata>::new();
223
224        for mut tool in tools {
225            let Some(identity) = ClassifiedToolIdentity::from_schema_name(&tool.canonical_name)
226            else {
227                continue;
228            };
229            tool.canonical_name = identity.execution_name().to_string();
230            if identity.loading_class() == CapabilityLoadingClass::HostOnly || !tool.available {
231                continue;
232            }
233            let key = tool.canonical_name.clone();
234            tool.summary = bounded_summary(&tool.summary);
235            tool.aliases.sort();
236            tool.aliases.dedup();
237            match projected_tools.get(&key) {
238                Some(existing) if !prefer_tool_projection(&tool, existing) => {}
239                _ => {
240                    projected_tools.insert(key, tool);
241                }
242            }
243        }
244
245        let disabled_execution_names = eligibility
246            .disabled_tool_names
247            .iter()
248            .filter_map(|reference| {
249                resolve_tool_reference_name(reference, |name| projected_tools.contains_key(name))
250            })
251            .collect::<BTreeSet<_>>();
252        let allowed_execution_names = eligibility.allowed_tool_names.as_ref().map(|references| {
253            references
254                .iter()
255                .filter_map(|reference| {
256                    resolve_tool_reference_name(reference, |name| {
257                        projected_tools.contains_key(name)
258                    })
259                })
260                .collect::<BTreeSet<_>>()
261        });
262        projected_tools.retain(|name, _| {
263            !disabled_execution_names.contains(name)
264                && allowed_execution_names
265                    .as_ref()
266                    .is_none_or(|allowed| allowed.contains(name))
267        });
268
269        let mut candidates = projected_tools
270            .into_values()
271            .map(index_tool)
272            .collect::<Vec<_>>();
273
274        if eligibility.skill_gateway_available {
275            candidates.extend(
276                skill_catalog
277                    .entries
278                    .iter()
279                    .filter(|entry| skill_entry_is_eligible(entry, eligibility))
280                    .map(|entry| index_catalog_entry(entry, CapabilityKind::Skill)),
281            );
282        }
283
284        if eligibility.workflow_gateway_available {
285            candidates.extend(
286                workflow_catalog
287                    .entries
288                    .iter()
289                    .filter(|entry| workflow_entry_is_eligible(entry, eligibility))
290                    .map(|entry| index_catalog_entry(entry, CapabilityKind::Workflow)),
291            );
292        }
293
294        candidates.sort_by(|left, right| {
295            left.value
296                .kind
297                .cmp(&right.value.kind)
298                .then_with(|| left.value.capability_ref.cmp(&right.value.capability_ref))
299        });
300        candidates.dedup_by(|left, right| {
301            left.value.kind == right.value.kind
302                && left.value.capability_ref == right.value.capability_ref
303        });
304
305        Self { candidates }
306    }
307
308    pub fn discover(
309        &self,
310        request: &DiscoverCapabilitiesRequest,
311    ) -> Result<DiscoverCapabilitiesResult, CapabilityDiscoveryError> {
312        let query = request.query.trim().to_string();
313        let query_chars = query.chars().count();
314        if query_chars > MAX_DISCOVERY_QUERY_CHARS {
315            return Err(CapabilityDiscoveryError::QueryTooLong {
316                actual: query_chars,
317                maximum: MAX_DISCOVERY_QUERY_CHARS,
318            });
319        }
320
321        let limit = request.limit.unwrap_or(MAX_DISCOVERY_RESULTS);
322        if limit > MAX_DISCOVERY_RESULTS {
323            return Err(CapabilityDiscoveryError::LimitTooLarge {
324                actual: limit,
325                maximum: MAX_DISCOVERY_RESULTS,
326            });
327        }
328
329        let kinds = match request.kinds.as_ref() {
330            Some(kinds) if kinds.len() > 3 => {
331                return Err(CapabilityDiscoveryError::TooManyKinds {
332                    actual: kinds.len(),
333                    maximum: 3,
334                });
335            }
336            Some(kinds) => Some(kinds.iter().copied().collect::<BTreeSet<_>>()),
337            None => None,
338        };
339
340        let empty = || DiscoverCapabilitiesResult {
341            query: query.clone(),
342            matches: Vec::new(),
343        };
344        if query.is_empty() || limit == 0 || kinds.as_ref().is_some_and(BTreeSet::is_empty) {
345            return Ok(empty());
346        }
347
348        let normalized_query = normalize_search_text(&query);
349        let query_tokens = search_tokens(&normalized_query);
350        if normalized_query.is_empty() || query_tokens.is_empty() {
351            return Ok(empty());
352        }
353        let tool_query = query
354            .strip_prefix("tool:")
355            .or_else(|| query.strip_prefix("tool/"))
356            .unwrap_or(&query);
357        let resolved_tool_query = resolve_tool_reference_name(tool_query, |name| {
358            self.candidates.iter().any(|candidate| {
359                matches!(
360                    &candidate.value.invocation_target,
361                    CapabilityInvocationTarget::Tool { name: registered } if registered == name
362                )
363            })
364        });
365
366        let mut ranked = self
367            .candidates
368            .iter()
369            .filter(|candidate| {
370                kinds
371                    .as_ref()
372                    .is_none_or(|kinds| kinds.contains(&candidate.value.kind))
373            })
374            .filter_map(|candidate| {
375                rank_candidate(
376                    candidate,
377                    &query,
378                    &normalized_query,
379                    &query_tokens,
380                    resolved_tool_query.as_deref(),
381                    None,
382                )
383                .map(|rank| (rank, candidate))
384            })
385            .collect::<Vec<_>>();
386
387        ranked.sort_by(|(left_rank, left), (right_rank, right)| {
388            right_rank
389                .cmp(left_rank)
390                .then_with(|| left.value.kind.cmp(&right.value.kind))
391                .then_with(|| left.value.capability_ref.cmp(&right.value.capability_ref))
392        });
393
394        Ok(DiscoverCapabilitiesResult {
395            query,
396            matches: ranked
397                .into_iter()
398                .take(limit)
399                .map(|(_, candidate)| candidate.value.clone())
400                .collect(),
401        })
402    }
403
404    /// Select one automatic Skill only when the best match is both strong and
405    /// semantically unambiguous. Deterministic source/reference tie-breakers
406    /// remain useful for generic discovery, but must never turn an ambiguous
407    /// automatic activation into an arbitrary choice.
408    pub fn discover_unambiguous_automatic_skill(&self, query: &str) -> Option<CapabilityMatch> {
409        self.explain_automatic_skill(query).selected
410    }
411
412    pub fn explain_automatic_skill(&self, query: &str) -> AutomaticSkillDecision {
413        use sha2::{Digest, Sha256};
414        let query = query
415            .trim()
416            .chars()
417            .take(MAX_DISCOVERY_QUERY_CHARS)
418            .collect::<String>();
419        let mut decision = AutomaticSkillDecision {
420            query_sha256: hex::encode(Sha256::digest(query.as_bytes())),
421            outcome: AutomaticSkillOutcome::EmptyQuery,
422            candidates: Vec::new(),
423            selected: None,
424        };
425        let normalized_query = normalize_search_text(&query);
426        let query_tokens = search_tokens(&normalized_query);
427        if normalized_query.is_empty() || query_tokens.is_empty() {
428            return decision;
429        }
430        let mut ranked = self
431            .candidates
432            .iter()
433            .filter(|candidate| candidate.value.kind == CapabilityKind::Skill)
434            .filter_map(|candidate| {
435                let summary = normalize_search_text(&candidate.value.summary);
436                rank_candidate(
437                    candidate,
438                    &query,
439                    &normalized_query,
440                    &query_tokens,
441                    None,
442                    Some(&summary),
443                )
444                .map(|rank| (rank, candidate))
445            })
446            .collect::<Vec<_>>();
447        ranked.sort_by(|(left_rank, left), (right_rank, right)| {
448            right_rank
449                .cmp(left_rank)
450                .then_with(|| left.value.capability_ref.cmp(&right.value.capability_ref))
451        });
452        decision.candidates = ranked
453            .iter()
454            .take(3)
455            .map(|(rank, candidate)| SkillMatchEvidence {
456                capability_ref: candidate.value.capability_ref.clone(),
457                source: candidate.value.source,
458                revision: candidate.value.revision,
459                exact_identity: rank.semantic.exact_identity,
460                name_token_hits: rank.semantic.name_token_hits,
461                summary_token_hits: rank.semantic.summary_token_hits,
462                strong_match: strong_fuzzy_skill_match(candidate, &query_tokens, rank.semantic),
463            })
464            .collect();
465        if ranked.is_empty() {
466            decision.outcome = AutomaticSkillOutcome::NoMatch;
467            return decision;
468        }
469        let exact = ranked
470            .iter()
471            .filter(|(rank, _)| rank.semantic.exact_identity)
472            .collect::<Vec<_>>();
473        if !exact.is_empty() {
474            if exact.len() == 1 {
475                decision.outcome = AutomaticSkillOutcome::ExactMatch;
476                decision.selected = Some(exact[0].1.value.clone());
477            } else {
478                decision.outcome = AutomaticSkillOutcome::Ambiguous;
479            }
480            return decision;
481        }
482        let strong = ranked
483            .iter()
484            .filter(|(rank, candidate)| {
485                strong_fuzzy_skill_match(candidate, &query_tokens, rank.semantic)
486            })
487            .collect::<Vec<_>>();
488        let Some((top_rank, top)) = strong.first() else {
489            decision.outcome = AutomaticSkillOutcome::BelowThreshold;
490            return decision;
491        };
492        if strong
493            .get(1)
494            .is_some_and(|(rank, _)| rank.semantic == top_rank.semantic)
495        {
496            decision.outcome = AutomaticSkillOutcome::Ambiguous;
497        } else {
498            decision.outcome = AutomaticSkillOutcome::StrongMatch;
499            decision.selected = Some(top.value.clone());
500        }
501        decision
502    }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Error)]
506pub enum CapabilityDiscoveryError {
507    #[error("discovery query has {actual} characters; maximum is {maximum}")]
508    QueryTooLong { actual: usize, maximum: usize },
509    #[error("discovery limit is {actual}; maximum is {maximum}")]
510    LimitTooLarge { actual: usize, maximum: usize },
511    #[error("discovery kinds has {actual} entries; maximum is {maximum}")]
512    TooManyKinds { actual: usize, maximum: usize },
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
516struct SemanticSearchRank {
517    exact_registered_identity: bool,
518    exact_identity: bool,
519    name_phrase: bool,
520    name_token_hits: usize,
521    summary_phrase: bool,
522    summary_token_hits: usize,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
526struct SearchRank {
527    semantic: SemanticSearchRank,
528    source_precedence: u8,
529}
530
531fn rank_candidate(
532    candidate: &IndexedCapability,
533    raw_query: &str,
534    normalized_query: &str,
535    query_tokens: &BTreeSet<String>,
536    resolved_tool_query: Option<&str>,
537    normalized_summary_override: Option<&str>,
538) -> Option<SearchRank> {
539    let exact_registered_identity = matches!(
540        (&candidate.value.invocation_target, resolved_tool_query),
541        (CapabilityInvocationTarget::Tool { name }, Some(resolved)) if name == resolved
542    );
543    let exact_identity = candidate
544        .normalized_names
545        .iter()
546        .any(|name| name == normalized_query)
547        || candidate
548            .value
549            .capability_ref
550            .eq_ignore_ascii_case(raw_query)
551        || tool_alias_exact_match(candidate, raw_query);
552    let name_phrase = candidate
553        .normalized_names
554        .iter()
555        .any(|name| name.contains(normalized_query));
556    let name_token_hits = candidate
557        .normalized_names
558        .iter()
559        .map(|name| token_hits(name, query_tokens))
560        .max()
561        .unwrap_or_default();
562    let normalized_summary =
563        normalized_summary_override.unwrap_or(candidate.normalized_summary.as_str());
564    let summary_phrase = normalized_summary.contains(normalized_query);
565    let summary_token_hits = token_hits(normalized_summary, query_tokens);
566
567    if !exact_registered_identity
568        && !exact_identity
569        && !name_phrase
570        && name_token_hits == 0
571        && !summary_phrase
572        && summary_token_hits == 0
573    {
574        return None;
575    }
576
577    Some(SearchRank {
578        semantic: SemanticSearchRank {
579            exact_registered_identity,
580            exact_identity,
581            name_phrase,
582            name_token_hits,
583            summary_phrase,
584            summary_token_hits,
585        },
586        source_precedence: source_precedence(candidate.value.source),
587    })
588}
589
590fn strong_fuzzy_skill_match(
591    candidate: &IndexedCapability,
592    query_tokens: &BTreeSet<String>,
593    semantic_rank: SemanticSearchRank,
594) -> bool {
595    let complete_identity_tokens = candidate.normalized_names.iter().any(|name| {
596        let identity_tokens = search_tokens(name);
597        !identity_tokens.is_empty() && identity_tokens.is_subset(query_tokens)
598    });
599    complete_identity_tokens || semantic_rank.summary_token_hits >= 2
600}
601
602fn tool_alias_exact_match(candidate: &IndexedCapability, raw_query: &str) -> bool {
603    if candidate.value.kind != CapabilityKind::Tool {
604        return false;
605    }
606    let query = raw_query
607        .strip_prefix("tool:")
608        .or_else(|| raw_query.strip_prefix("tool/"))
609        .unwrap_or(raw_query);
610    let canonical = canonical_registry_tool_name(query);
611    matches!(
612        &candidate.value.invocation_target,
613        CapabilityInvocationTarget::Tool { name } if name == &canonical
614    )
615}
616
617fn token_hits(haystack: &str, query_tokens: &BTreeSet<String>) -> usize {
618    let haystack_tokens = search_tokens(haystack);
619    query_tokens
620        .iter()
621        .filter(|token| haystack_tokens.contains(*token))
622        .count()
623}
624
625fn index_tool(tool: ToolCapabilityMetadata) -> IndexedCapability {
626    let capability_ref = format!("tool:{}", tool.canonical_name);
627    let mut normalized_names = vec![
628        normalize_search_text(&capability_ref),
629        normalize_search_text(&tool.canonical_name),
630    ];
631    normalized_names.extend(
632        tool.aliases
633            .iter()
634            .map(|alias| normalize_search_text(alias)),
635    );
636    normalized_names.sort();
637    normalized_names.dedup();
638
639    IndexedCapability {
640        normalized_summary: normalize_search_text(&tool.summary),
641        value: CapabilityMatch {
642            capability_ref,
643            kind: CapabilityKind::Tool,
644            display_name: tool.canonical_name.clone(),
645            summary: tool.summary,
646            source: tool.source,
647            revision: None,
648            status: CapabilityStatus::Available,
649            invocation_policy: None,
650            invocation_target: CapabilityInvocationTarget::Tool {
651                name: tool.canonical_name,
652            },
653        },
654        normalized_names,
655    }
656}
657
658fn index_catalog_entry(entry: &WorkflowCatalogEntry, kind: CapabilityKind) -> IndexedCapability {
659    let source = capability_source(entry.source);
660    let summary = bounded_summary(&entry.description);
661    let capability_ref = match kind {
662        CapabilityKind::Skill => format!("skill:{}", entry.id),
663        CapabilityKind::Workflow => format!("workflow:{}", entry.id),
664        CapabilityKind::Tool => unreachable!("catalog entries are never tool candidates"),
665    };
666    let policy = invocation_policy(entry);
667    let invocation_target = match kind {
668        CapabilityKind::Skill => CapabilityInvocationTarget::Skill {
669            name: "load_skill".to_string(),
670            skill_id: entry.id.clone(),
671            source,
672            revision: entry.revision,
673        },
674        CapabilityKind::Workflow => CapabilityInvocationTarget::Workflow {
675            name: "workflow_run".to_string(),
676            workflow_id: entry.id.clone(),
677            source,
678            revision: entry.revision,
679        },
680        CapabilityKind::Tool => unreachable!("catalog entries are never tool candidates"),
681    };
682
683    let mut policy_hints = Vec::new();
684    if policy.explicit {
685        policy_hints.push("explicit");
686    }
687    if policy.automatic {
688        policy_hints.push("automatic");
689    }
690    let policy_hint = policy_hints.join(" ");
691    IndexedCapability {
692        normalized_names: vec![
693            normalize_search_text(&capability_ref),
694            normalize_search_text(&entry.id),
695            normalize_search_text(&entry.name),
696        ],
697        normalized_summary: normalize_search_text(&format!("{summary} {policy_hint}")),
698        value: CapabilityMatch {
699            capability_ref,
700            kind,
701            display_name: entry.name.clone(),
702            summary,
703            source,
704            revision: Some(entry.revision),
705            status: CapabilityStatus::Valid,
706            invocation_policy: Some(policy),
707            invocation_target,
708        },
709    }
710}
711
712fn skill_entry_is_eligible(
713    entry: &WorkflowCatalogEntry,
714    eligibility: &CapabilityDiscoveryEligibility,
715) -> bool {
716    catalog_entry_is_valid(entry)
717        && entry.kind == WorkflowKind::Instruction
718        && !entry.legacy
719        && !eligibility.disabled_skill_ids.contains(&entry.id)
720        && eligibility
721            .allowed_skill_ids
722            .as_ref()
723            .is_none_or(|ids| ids.contains(&entry.id))
724        && eligibility
725            .skill_invocation
726            .permits(invocation_policy(entry))
727}
728
729fn workflow_entry_is_eligible(
730    entry: &WorkflowCatalogEntry,
731    eligibility: &CapabilityDiscoveryEligibility,
732) -> bool {
733    catalog_entry_is_valid(entry)
734        && entry.kind == WorkflowKind::Orchestration
735        && entry.is_public_workflow()
736        && !eligibility.disabled_skill_ids.contains(&entry.id)
737        && eligibility
738            .workflow_invocation
739            .permits(invocation_policy(entry))
740}
741
742fn catalog_entry_is_valid(entry: &WorkflowCatalogEntry) -> bool {
743    entry.winner
744        && entry.status == WorkflowStatus::Valid
745        && !entry.id.trim().is_empty()
746        && !entry.name.trim().is_empty()
747}
748
749fn invocation_policy(entry: &WorkflowCatalogEntry) -> CapabilityInvocationPolicy {
750    CapabilityInvocationPolicy {
751        explicit: entry.invocation_policy["explicit"].as_bool() == Some(true),
752        automatic: entry.invocation_policy["automatic"].as_bool() == Some(true),
753    }
754}
755
756pub(crate) fn capability_source(source: WorkflowSource) -> CapabilitySource {
757    match source {
758        WorkflowSource::Builtin => CapabilitySource::Builtin,
759        WorkflowSource::Project => CapabilitySource::Project,
760        WorkflowSource::Workspace => CapabilitySource::Workspace,
761        WorkflowSource::User => CapabilitySource::User,
762        WorkflowSource::Plugin => CapabilitySource::Plugin,
763    }
764}
765
766fn source_precedence(source: CapabilitySource) -> u8 {
767    match source {
768        CapabilitySource::Workspace => 8,
769        CapabilitySource::Project => 7,
770        CapabilitySource::User => 6,
771        CapabilitySource::Plugin => 5,
772        CapabilitySource::Builtin => 4,
773        CapabilitySource::Server => 3,
774        CapabilitySource::Mcp => 2,
775        CapabilitySource::Custom => 1,
776    }
777}
778
779fn prefer_tool_projection(
780    candidate: &ToolCapabilityMetadata,
781    existing: &ToolCapabilityMetadata,
782) -> bool {
783    source_precedence(candidate.source)
784        .cmp(&source_precedence(existing.source))
785        .then_with(|| candidate.summary.cmp(&existing.summary))
786        .then_with(|| candidate.canonical_name.cmp(&existing.canonical_name))
787        .is_gt()
788}
789
790fn source_for_tool(name: &str) -> CapabilitySource {
791    if bamboo_domain::BUILTIN_TOOL_NAMES.contains(&name) {
792        CapabilitySource::Builtin
793    } else if bamboo_domain::SERVER_CAPABILITY_NAMES.contains(&name) {
794        CapabilitySource::Server
795    } else if name.starts_with("mcp__") {
796        CapabilitySource::Mcp
797    } else {
798        CapabilitySource::Custom
799    }
800}
801
802fn aliases_for_tool(canonical_name: &str) -> Vec<String> {
803    bamboo_domain::BUILTIN_TOOL_ALIASES
804        .iter()
805        .filter(|(_, canonical)| *canonical == canonical_name)
806        .map(|(alias, _)| (*alias).to_string())
807        .collect()
808}
809
810pub(crate) fn bounded_summary(value: &str) -> String {
811    let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
812    if compact.chars().count() <= MAX_CAPABILITY_SUMMARY_CHARS {
813        return compact;
814    }
815    let mut bounded = compact
816        .chars()
817        .take(MAX_CAPABILITY_SUMMARY_CHARS.saturating_sub(1))
818        .collect::<String>();
819    bounded.push('…');
820    bounded
821}
822
823fn normalize_search_text(value: &str) -> String {
824    let mut normalized = String::new();
825    let mut previous_was_lower_or_digit = false;
826    let mut pending_space = false;
827
828    for character in value.chars() {
829        if character.is_alphanumeric() {
830            if (pending_space || (character.is_uppercase() && previous_was_lower_or_digit))
831                && !normalized.is_empty()
832            {
833                normalized.push(' ');
834            }
835            for lowercase in character.to_lowercase() {
836                normalized.push(lowercase);
837            }
838            previous_was_lower_or_digit = character.is_lowercase() || character.is_numeric();
839            pending_space = false;
840        } else {
841            pending_space = true;
842            previous_was_lower_or_digit = false;
843        }
844    }
845
846    normalized.trim().to_string()
847}
848
849fn search_tokens(normalized: &str) -> BTreeSet<String> {
850    normalized
851        .split_whitespace()
852        .filter(|token| !token.is_empty())
853        .map(str::to_string)
854        .collect()
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use bamboo_domain::FunctionSchema;
861    use serde_json::json;
862
863    use crate::{ShadowedWorkflowCandidate, WorkflowCatalogSnapshot};
864
865    fn schema(name: &str, description: &str, parameters: serde_json::Value) -> ToolSchema {
866        ToolSchema {
867            schema_type: "function".to_string(),
868            function: FunctionSchema {
869                name: name.to_string(),
870                description: description.to_string(),
871                parameters,
872            },
873        }
874    }
875
876    fn catalog_entry(
877        id: &str,
878        name: &str,
879        description: &str,
880        kind: WorkflowKind,
881        source: WorkflowSource,
882        revision: u64,
883    ) -> WorkflowCatalogEntry {
884        WorkflowCatalogEntry {
885            id: id.to_string(),
886            name: name.to_string(),
887            description: description.to_string(),
888            kind,
889            source,
890            revision,
891            content_digest: "digest".to_string(),
892            version: "1".to_string(),
893            invocation_policy: json!({"explicit": true, "automatic": true}),
894            argument_schema: json!({"type": "object"}),
895            status: WorkflowStatus::Valid,
896            legacy: false,
897            migration_status: None,
898            last_error: None,
899            winner: true,
900            shadowed_candidates: Vec::new(),
901        }
902    }
903
904    fn request(query: &str) -> DiscoverCapabilitiesRequest {
905        DiscoverCapabilitiesRequest {
906            query: query.to_string(),
907            kinds: None,
908            limit: None,
909        }
910    }
911
912    fn index(
913        schemas: &[ToolSchema],
914        skills: Vec<WorkflowCatalogEntry>,
915        workflows: Vec<WorkflowCatalogEntry>,
916        eligibility: CapabilityDiscoveryEligibility,
917    ) -> CapabilityDiscoveryIndex {
918        CapabilityDiscoveryIndex::from_snapshots(
919            project_tool_capability_metadata(schemas),
920            &WorkflowCatalogSnapshot {
921                revision: 11,
922                entries: skills,
923            },
924            &WorkflowCatalogSnapshot {
925                revision: 12,
926                entries: workflows,
927            },
928            &eligibility,
929        )
930    }
931
932    #[test]
933    fn one_query_searches_tools_skills_and_workflows_with_typed_targets() {
934        let schemas = [schema(
935            "GitInspect",
936            "Inspect repository status, history, and diffs",
937            json!({"type": "object"}),
938        )];
939        let mut skill = catalog_entry(
940            "review-helper",
941            "Review Helper",
942            "Inspect a change before review",
943            WorkflowKind::Instruction,
944            WorkflowSource::User,
945            7,
946        );
947        skill.invocation_policy = json!({
948            "explicit": true,
949            "automatic": true,
950            "requires_confirmation": true
951        });
952        let workflow = catalog_entry(
953            "review-pipeline",
954            "Review Pipeline",
955            "Inspect and review a repository change",
956            WorkflowKind::Orchestration,
957            WorkflowSource::Workspace,
958            9,
959        );
960        let result = index(
961            &schemas,
962            vec![skill],
963            vec![workflow],
964            CapabilityDiscoveryEligibility::default(),
965        )
966        .discover(&request("inspect repository change"))
967        .expect("bounded discovery");
968
969        assert_eq!(result.query, "inspect repository change");
970        assert_eq!(result.matches.len(), 3);
971        assert_eq!(
972            result
973                .matches
974                .iter()
975                .map(|candidate| candidate.kind)
976                .collect::<BTreeSet<_>>(),
977            BTreeSet::from([
978                CapabilityKind::Tool,
979                CapabilityKind::Skill,
980                CapabilityKind::Workflow,
981            ])
982        );
983        assert!(result.matches.iter().any(|candidate| matches!(
984            &candidate.invocation_target,
985            CapabilityInvocationTarget::Skill {
986                name,
987                skill_id,
988                source: CapabilitySource::User,
989                revision: 7,
990            } if name == "load_skill" && skill_id == "review-helper"
991        )));
992        assert_eq!(
993            result
994                .matches
995                .iter()
996                .find(|candidate| candidate.capability_ref == "skill:review-helper")
997                .and_then(|candidate| candidate.invocation_policy),
998            Some(CapabilityInvocationPolicy {
999                explicit: true,
1000                automatic: true,
1001            })
1002        );
1003        assert!(result.matches.iter().any(|candidate| matches!(
1004            &candidate.invocation_target,
1005            CapabilityInvocationTarget::Workflow {
1006                name,
1007                workflow_id,
1008                source: CapabilitySource::Workspace,
1009                revision: 9,
1010            } if name == "workflow_run" && workflow_id == "review-pipeline"
1011        )));
1012    }
1013
1014    #[test]
1015    fn kind_filter_limit_and_empty_results_are_bounded() {
1016        let skill = catalog_entry(
1017            "inspect-skill",
1018            "Inspect Skill",
1019            "Inspect source",
1020            WorkflowKind::Instruction,
1021            WorkflowSource::Builtin,
1022            1,
1023        );
1024        let index = index(
1025            &[schema("InspectTool", "Inspect source", json!({}))],
1026            vec![skill],
1027            Vec::new(),
1028            CapabilityDiscoveryEligibility::default(),
1029        );
1030        let result = index
1031            .discover(&DiscoverCapabilitiesRequest {
1032                query: "inspect".to_string(),
1033                kinds: Some(vec![CapabilityKind::Skill]),
1034                limit: Some(1),
1035            })
1036            .expect("filtered discovery");
1037        assert_eq!(result.matches.len(), 1);
1038        assert_eq!(result.matches[0].kind, CapabilityKind::Skill);
1039
1040        for query in ["", "   ", "no-such-capability"] {
1041            assert!(index
1042                .discover(&request(query))
1043                .expect("typed empty result")
1044                .matches
1045                .is_empty());
1046        }
1047        assert!(index
1048            .discover(&DiscoverCapabilitiesRequest {
1049                query: "inspect".to_string(),
1050                kinds: Some(Vec::new()),
1051                limit: None,
1052            })
1053            .expect("empty kind set")
1054            .matches
1055            .is_empty());
1056    }
1057
1058    #[test]
1059    fn validation_rejects_unbounded_requests() {
1060        let index = CapabilityDiscoveryIndex::default();
1061        assert!(matches!(
1062            index.discover(&request(&"q".repeat(MAX_DISCOVERY_QUERY_CHARS + 1))),
1063            Err(CapabilityDiscoveryError::QueryTooLong { .. })
1064        ));
1065        assert!(matches!(
1066            index.discover(&DiscoverCapabilitiesRequest {
1067                query: "query".to_string(),
1068                kinds: None,
1069                limit: Some(MAX_DISCOVERY_RESULTS + 1),
1070            }),
1071            Err(CapabilityDiscoveryError::LimitTooLarge { .. })
1072        ));
1073        assert!(matches!(
1074            index.discover(&DiscoverCapabilitiesRequest {
1075                query: "query".to_string(),
1076                kinds: Some(vec![
1077                    CapabilityKind::Tool,
1078                    CapabilityKind::Skill,
1079                    CapabilityKind::Workflow,
1080                    CapabilityKind::Tool,
1081                ]),
1082                limit: None,
1083            }),
1084            Err(CapabilityDiscoveryError::TooManyKinds { .. })
1085        ));
1086    }
1087
1088    #[test]
1089    fn default_limit_never_returns_more_than_five_matches() {
1090        let schemas = (0..8)
1091            .map(|index| schema(&format!("Inspect{index}"), "inspect source", json!({})))
1092            .collect::<Vec<_>>();
1093        let result = index(
1094            &schemas,
1095            Vec::new(),
1096            Vec::new(),
1097            CapabilityDiscoveryEligibility::default(),
1098        )
1099        .discover(&request("inspect"))
1100        .expect("default bounded result");
1101
1102        assert_eq!(result.matches.len(), MAX_DISCOVERY_RESULTS);
1103    }
1104
1105    #[test]
1106    fn exact_alias_wins_and_serialization_is_deterministic() {
1107        let schemas = [
1108            schema("Edit", "Apply a patch to a file", json!({})),
1109            schema("PatchAdvisor", "Explain apply_patch usage", json!({})),
1110        ];
1111        let index = index(
1112            &schemas,
1113            Vec::new(),
1114            Vec::new(),
1115            CapabilityDiscoveryEligibility::default(),
1116        );
1117        let first = index.discover(&request("apply_patch")).expect("alias");
1118        let second = index.discover(&request("apply_patch")).expect("alias");
1119
1120        assert_eq!(first.matches[0].capability_ref, "tool:Edit");
1121        assert_eq!(
1122            serde_json::to_string(&first).expect("serialize"),
1123            serde_json::to_string(&second).expect("serialize")
1124        );
1125    }
1126
1127    #[test]
1128    fn exact_registered_identity_ranks_ahead_of_alias_and_case_fallbacks() {
1129        let index = index(
1130            &[
1131                schema("Edit", "builtin edit", json!({})),
1132                schema("apply_patch", "custom exact patch", json!({})),
1133                schema("Foo", "upper custom", json!({})),
1134                schema("foo", "lower custom", json!({})),
1135            ],
1136            Vec::new(),
1137            Vec::new(),
1138            CapabilityDiscoveryEligibility::default(),
1139        );
1140
1141        let patch = index
1142            .discover(&request("apply_patch"))
1143            .expect("exact custom alias query");
1144        assert_eq!(patch.matches[0].capability_ref, "tool:apply_patch");
1145        let legacy_patch = index
1146            .discover(&request("applyPatch"))
1147            .expect("legacy spelling resolves through exact custom alias");
1148        assert_eq!(legacy_patch.matches[0].capability_ref, "tool:apply_patch");
1149
1150        let lower = index.discover(&request("foo")).expect("exact case query");
1151        assert_eq!(lower.matches[0].capability_ref, "tool:foo");
1152        let upper = index.discover(&request("Foo")).expect("exact case query");
1153        assert_eq!(upper.matches[0].capability_ref, "tool:Foo");
1154    }
1155
1156    #[test]
1157    fn serialization_is_byte_identical_across_snapshot_input_order() {
1158        let tools = vec![
1159            ToolCapabilityMetadata {
1160                canonical_name: "InspectBeta".to_string(),
1161                summary: "inspect source".to_string(),
1162                source: CapabilitySource::Custom,
1163                aliases: Vec::new(),
1164                available: true,
1165            },
1166            ToolCapabilityMetadata {
1167                canonical_name: "InspectAlpha".to_string(),
1168                summary: "inspect source".to_string(),
1169                source: CapabilitySource::Custom,
1170                aliases: Vec::new(),
1171                available: true,
1172            },
1173        ];
1174        let skills = vec![
1175            catalog_entry(
1176                "inspect-beta",
1177                "Inspect Beta",
1178                "inspect source",
1179                WorkflowKind::Instruction,
1180                WorkflowSource::User,
1181                2,
1182            ),
1183            catalog_entry(
1184                "inspect-alpha",
1185                "Inspect Alpha",
1186                "inspect source",
1187                WorkflowKind::Instruction,
1188                WorkflowSource::User,
1189                1,
1190            ),
1191        ];
1192        let build = |tools: Vec<ToolCapabilityMetadata>, skills: Vec<WorkflowCatalogEntry>| {
1193            CapabilityDiscoveryIndex::from_snapshots(
1194                tools,
1195                &WorkflowCatalogSnapshot {
1196                    revision: 1,
1197                    entries: skills,
1198                },
1199                &WorkflowCatalogSnapshot::default(),
1200                &CapabilityDiscoveryEligibility::default(),
1201            )
1202            .discover(&request("inspect source"))
1203            .expect("deterministic discovery")
1204        };
1205
1206        let mut reversed_tools = tools.clone();
1207        reversed_tools.reverse();
1208        let mut reversed_skills = skills.clone();
1209        reversed_skills.reverse();
1210        let first = build(tools, skills);
1211        let second = build(reversed_tools, reversed_skills);
1212
1213        assert_eq!(
1214            serde_json::to_vec(&first).expect("serialize first"),
1215            serde_json::to_vec(&second).expect("serialize second")
1216        );
1217    }
1218
1219    #[test]
1220    fn ranking_uses_exact_then_name_summary_source_and_stable_ref() {
1221        let mut builtin = catalog_entry(
1222            "review-b",
1223            "Review",
1224            "Review a change",
1225            WorkflowKind::Instruction,
1226            WorkflowSource::Builtin,
1227            1,
1228        );
1229        let workspace = catalog_entry(
1230            "review-z",
1231            "Review",
1232            "Review a change",
1233            WorkflowKind::Instruction,
1234            WorkflowSource::Workspace,
1235            2,
1236        );
1237        let workspace_a = catalog_entry(
1238            "review-a",
1239            "Review",
1240            "Review a change",
1241            WorkflowKind::Instruction,
1242            WorkflowSource::Workspace,
1243            3,
1244        );
1245        builtin.description = "Review a change with details".to_string();
1246        let index = index(
1247            &[],
1248            vec![builtin, workspace, workspace_a],
1249            Vec::new(),
1250            CapabilityDiscoveryEligibility::default(),
1251        );
1252        let ranked = index.discover(&request("review")).expect("ranked");
1253        assert_eq!(ranked.matches[0].capability_ref, "skill:review-a");
1254        assert_eq!(ranked.matches[1].capability_ref, "skill:review-z");
1255
1256        let exact = index.discover(&request("review-b")).expect("exact");
1257        assert_eq!(exact.matches[0].capability_ref, "skill:review-b");
1258    }
1259
1260    #[test]
1261    fn automatic_skill_accepts_unique_exact_normalized_identities() {
1262        let skill = catalog_entry(
1263            "review-helper",
1264            "Review Helper",
1265            "Review a code change carefully",
1266            WorkflowKind::Instruction,
1267            WorkflowSource::User,
1268            7,
1269        );
1270        let index = index(
1271            &[],
1272            vec![skill],
1273            Vec::new(),
1274            CapabilityDiscoveryEligibility {
1275                skill_invocation: InvocationEligibility::Automatic,
1276                ..Default::default()
1277            },
1278        );
1279
1280        for query in ["review-helper", "skill:review-helper", "Review_Helper"] {
1281            let matched = index
1282                .discover_unambiguous_automatic_skill(query)
1283                .unwrap_or_else(|| panic!("unique exact match for {query}"));
1284            assert_eq!(matched.capability_ref, "skill:review-helper");
1285            assert!(matches!(
1286                matched.invocation_target,
1287                CapabilityInvocationTarget::Skill {
1288                    skill_id,
1289                    source: CapabilitySource::User,
1290                    revision: 7,
1291                    ..
1292                } if skill_id == "review-helper"
1293            ));
1294        }
1295    }
1296
1297    #[test]
1298    fn automatic_skill_accepts_only_unique_strong_fuzzy_match() {
1299        let react = catalog_entry(
1300            "react-optimizer",
1301            "React Optimizer",
1302            "Improve React and Vite build performance",
1303            WorkflowKind::Instruction,
1304            WorkflowSource::Project,
1305            11,
1306        );
1307        let index = index(
1308            &[],
1309            vec![react],
1310            Vec::new(),
1311            CapabilityDiscoveryEligibility {
1312                skill_invocation: InvocationEligibility::Automatic,
1313                ..Default::default()
1314            },
1315        );
1316
1317        let matched = index
1318            .discover_unambiguous_automatic_skill("please improve the react vite build")
1319            .expect("multiple distinct summary tokens are a strong fuzzy match");
1320        assert_eq!(matched.capability_ref, "skill:react-optimizer");
1321    }
1322
1323    #[test]
1324    fn automatic_skill_ignores_a_higher_ranked_weak_candidate() {
1325        let weak = catalog_entry(
1326            "only-alpha",
1327            "only-alpha",
1328            "only-alpha project skill",
1329            WorkflowKind::Instruction,
1330            WorkflowSource::Workspace,
1331            1,
1332        );
1333        let strong = catalog_entry(
1334            "shared-workflow",
1335            "shared-workflow",
1336            "alpha needle workflow",
1337            WorkflowKind::Instruction,
1338            WorkflowSource::Workspace,
1339            2,
1340        );
1341        let index = index(
1342            &[],
1343            vec![weak, strong],
1344            Vec::new(),
1345            CapabilityDiscoveryEligibility {
1346                skill_invocation: InvocationEligibility::Automatic,
1347                ..Default::default()
1348            },
1349        );
1350
1351        assert_eq!(
1352            index
1353                .discover_unambiguous_automatic_skill("alpha needle")
1354                .expect("the unique strong summary match")
1355                .capability_ref,
1356            "skill:shared-workflow"
1357        );
1358    }
1359
1360    #[test]
1361    fn automatic_skill_abstains_for_weak_tied_and_missing_matches() {
1362        let weak = catalog_entry(
1363            "artifact-helper",
1364            "Artifact Helper",
1365            "Handles PDF artifacts",
1366            WorkflowKind::Instruction,
1367            WorkflowSource::User,
1368            1,
1369        );
1370        let first = catalog_entry(
1371            "review-first",
1372            "Review Assistant",
1373            "Review code changes carefully",
1374            WorkflowKind::Instruction,
1375            WorkflowSource::User,
1376            2,
1377        );
1378        let mut second = catalog_entry(
1379            "review-second",
1380            "Review Assistant",
1381            "Review code changes carefully",
1382            WorkflowKind::Instruction,
1383            WorkflowSource::Workspace,
1384            3,
1385        );
1386        second.invocation_policy = json!({"explicit": false, "automatic": true});
1387        let policy_only_index = index(
1388            &[],
1389            vec![weak.clone()],
1390            Vec::new(),
1391            CapabilityDiscoveryEligibility {
1392                skill_invocation: InvocationEligibility::Automatic,
1393                ..Default::default()
1394            },
1395        );
1396        assert!(policy_only_index
1397            .discover_unambiguous_automatic_skill("please use explicit automatic mode")
1398            .is_none());
1399        let index = index(
1400            &[],
1401            vec![weak, first, second],
1402            Vec::new(),
1403            CapabilityDiscoveryEligibility {
1404                skill_invocation: InvocationEligibility::Automatic,
1405                ..Default::default()
1406            },
1407        );
1408
1409        assert!(index.discover_unambiguous_automatic_skill("pdf").is_none());
1410        assert!(index
1411            .discover_unambiguous_automatic_skill("please review code changes")
1412            .is_none());
1413        assert!(index
1414            .discover_unambiguous_automatic_skill("explicit review code")
1415            .is_none());
1416        assert!(index
1417            .discover_unambiguous_automatic_skill("unrelated request")
1418            .is_none());
1419        assert!(index.discover_unambiguous_automatic_skill("").is_none());
1420    }
1421
1422    #[test]
1423    fn automatic_skill_abstains_when_an_exact_display_name_is_not_unique() {
1424        let first = catalog_entry(
1425            "review-first",
1426            "Shared Review",
1427            "Review code",
1428            WorkflowKind::Instruction,
1429            WorkflowSource::User,
1430            1,
1431        );
1432        let second = catalog_entry(
1433            "review-second",
1434            "Shared Review",
1435            "Review another change",
1436            WorkflowKind::Instruction,
1437            WorkflowSource::Workspace,
1438            2,
1439        );
1440        let index = index(
1441            &[],
1442            vec![first, second],
1443            Vec::new(),
1444            CapabilityDiscoveryEligibility {
1445                skill_invocation: InvocationEligibility::Automatic,
1446                ..Default::default()
1447            },
1448        );
1449
1450        assert!(index
1451            .discover_unambiguous_automatic_skill("Shared Review")
1452            .is_none());
1453        assert_eq!(
1454            index
1455                .discover_unambiguous_automatic_skill("review-first")
1456                .expect("canonical id remains unique")
1457                .capability_ref,
1458            "skill:review-first"
1459        );
1460    }
1461
1462    #[test]
1463    fn automatic_skill_bounds_long_user_hints_without_failing() {
1464        let skill = catalog_entry(
1465            "review",
1466            "Review",
1467            "Review code changes",
1468            WorkflowKind::Instruction,
1469            WorkflowSource::User,
1470            1,
1471        );
1472        let index = index(
1473            &[],
1474            vec![skill],
1475            Vec::new(),
1476            CapabilityDiscoveryEligibility {
1477                skill_invocation: InvocationEligibility::Automatic,
1478                ..Default::default()
1479            },
1480        );
1481        let hint = format!("review {}", "界".repeat(MAX_DISCOVERY_QUERY_CHARS + 20));
1482
1483        assert_eq!(
1484            index
1485                .discover_unambiguous_automatic_skill(&hint)
1486                .expect("bounded prefix keeps the match")
1487                .capability_ref,
1488            "skill:review"
1489        );
1490        assert!(matches!(
1491            index.discover(&request(&hint)),
1492            Err(CapabilityDiscoveryError::QueryTooLong { .. })
1493        ));
1494    }
1495
1496    #[test]
1497    fn disabled_surface_ineligible_invalid_shadowed_and_unavailable_entries_are_filtered() {
1498        let mut invalid = catalog_entry(
1499            "invalid-skill",
1500            "Inspect Invalid",
1501            "inspect",
1502            WorkflowKind::Instruction,
1503            WorkflowSource::User,
1504            1,
1505        );
1506        invalid.status = WorkflowStatus::Invalid;
1507        invalid.last_error = Some("private invalid diagnostic".to_string());
1508        let mut shadowed = catalog_entry(
1509            "shadowed-skill",
1510            "Inspect Shadowed",
1511            "inspect",
1512            WorkflowKind::Instruction,
1513            WorkflowSource::User,
1514            2,
1515        );
1516        shadowed.winner = false;
1517        let disabled = catalog_entry(
1518            "disabled-skill",
1519            "Inspect Disabled",
1520            "inspect",
1521            WorkflowKind::Instruction,
1522            WorkflowSource::User,
1523            3,
1524        );
1525        let mut denied = catalog_entry(
1526            "denied-skill",
1527            "Inspect Denied",
1528            "inspect",
1529            WorkflowKind::Instruction,
1530            WorkflowSource::User,
1531            4,
1532        );
1533        denied.invocation_policy = json!({"explicit": false, "automatic": false});
1534        let allowed = catalog_entry(
1535            "allowed-skill",
1536            "Inspect Allowed",
1537            "inspect",
1538            WorkflowKind::Instruction,
1539            WorkflowSource::User,
1540            5,
1541        );
1542        let eligibility = CapabilityDiscoveryEligibility {
1543            disabled_tool_names: BTreeSet::from(["HiddenTool".to_string()]),
1544            allowed_tool_names: Some(BTreeSet::from(["VisibleTool".to_string()])),
1545            disabled_skill_ids: BTreeSet::from(["disabled-skill".to_string()]),
1546            allowed_skill_ids: Some(BTreeSet::from(["allowed-skill".to_string()])),
1547            workflow_gateway_available: false,
1548            ..Default::default()
1549        };
1550        let workflow = catalog_entry(
1551            "hidden-workflow",
1552            "Inspect Workflow",
1553            "inspect",
1554            WorkflowKind::Orchestration,
1555            WorkflowSource::User,
1556            6,
1557        );
1558        let index = index(
1559            &[
1560                schema("HiddenTool", "inspect", json!({})),
1561                schema("SurfaceHiddenTool", "inspect", json!({})),
1562                schema("VisibleTool", "inspect", json!({})),
1563            ],
1564            vec![invalid, shadowed, disabled, denied, allowed],
1565            vec![workflow],
1566            eligibility,
1567        );
1568        let result = index.discover(&request("inspect")).expect("filtered");
1569        assert_eq!(
1570            result
1571                .matches
1572                .iter()
1573                .map(|candidate| candidate.capability_ref.as_str())
1574                .collect::<BTreeSet<_>>(),
1575            BTreeSet::from(["skill:allowed-skill", "tool:VisibleTool"])
1576        );
1577    }
1578
1579    #[test]
1580    fn each_catalog_eligibility_predicate_fails_closed_independently() {
1581        let is_visible = |entry: WorkflowCatalogEntry,
1582                          eligibility: CapabilityDiscoveryEligibility| {
1583            let query = entry.id.clone();
1584            !index(&[], vec![entry], Vec::new(), eligibility)
1585                .discover(&request(&query))
1586                .expect("eligibility query")
1587                .matches
1588                .is_empty()
1589        };
1590
1591        let eligible = catalog_entry(
1592            "eligible-skill",
1593            "Eligible Skill",
1594            "inspect",
1595            WorkflowKind::Instruction,
1596            WorkflowSource::User,
1597            1,
1598        );
1599        assert!(is_visible(
1600            eligible.clone(),
1601            CapabilityDiscoveryEligibility::default()
1602        ));
1603
1604        let mut invalid = eligible.clone();
1605        invalid.status = WorkflowStatus::Invalid;
1606        assert!(!is_visible(
1607            invalid,
1608            CapabilityDiscoveryEligibility::default()
1609        ));
1610
1611        let mut shadowed = eligible.clone();
1612        shadowed.winner = false;
1613        assert!(!is_visible(
1614            shadowed,
1615            CapabilityDiscoveryEligibility::default()
1616        ));
1617
1618        assert!(!is_visible(
1619            eligible.clone(),
1620            CapabilityDiscoveryEligibility {
1621                disabled_skill_ids: BTreeSet::from([eligible.id.clone()]),
1622                ..Default::default()
1623            }
1624        ));
1625
1626        let mut denied = eligible.clone();
1627        denied.invocation_policy = json!({"explicit": false, "automatic": false});
1628        assert!(!is_visible(
1629            denied,
1630            CapabilityDiscoveryEligibility::default()
1631        ));
1632
1633        let mut explicit_only = eligible.clone();
1634        explicit_only.invocation_policy = json!({"explicit": true, "automatic": false});
1635        assert!(is_visible(
1636            explicit_only.clone(),
1637            CapabilityDiscoveryEligibility {
1638                skill_invocation: InvocationEligibility::Explicit,
1639                ..Default::default()
1640            }
1641        ));
1642        assert!(!is_visible(
1643            explicit_only,
1644            CapabilityDiscoveryEligibility {
1645                skill_invocation: InvocationEligibility::Automatic,
1646                ..Default::default()
1647            }
1648        ));
1649
1650        assert!(!is_visible(
1651            eligible,
1652            CapabilityDiscoveryEligibility {
1653                skill_gateway_available: false,
1654                ..Default::default()
1655            }
1656        ));
1657    }
1658
1659    #[test]
1660    fn tool_disable_and_surface_allowlist_are_independent() {
1661        let tools = [
1662            ToolCapabilityMetadata {
1663                canonical_name: "DisabledTool".to_string(),
1664                summary: "inspect".to_string(),
1665                source: CapabilitySource::Custom,
1666                aliases: Vec::new(),
1667                available: true,
1668            },
1669            ToolCapabilityMetadata {
1670                canonical_name: "AllowedTool".to_string(),
1671                summary: "inspect".to_string(),
1672                source: CapabilitySource::Custom,
1673                aliases: Vec::new(),
1674                available: true,
1675            },
1676            ToolCapabilityMetadata {
1677                canonical_name: "SurfaceHiddenTool".to_string(),
1678                summary: "inspect".to_string(),
1679                source: CapabilitySource::Custom,
1680                aliases: Vec::new(),
1681                available: true,
1682            },
1683        ];
1684        let disabled_result = CapabilityDiscoveryIndex::from_snapshots(
1685            tools.clone(),
1686            &WorkflowCatalogSnapshot::default(),
1687            &WorkflowCatalogSnapshot::default(),
1688            &CapabilityDiscoveryEligibility {
1689                disabled_tool_names: BTreeSet::from(["DisabledTool".to_string()]),
1690                ..Default::default()
1691            },
1692        )
1693        .discover(&request("inspect"))
1694        .expect("disabled tool eligibility");
1695        assert_eq!(
1696            disabled_result
1697                .matches
1698                .iter()
1699                .map(|candidate| candidate.capability_ref.as_str())
1700                .collect::<BTreeSet<_>>(),
1701            BTreeSet::from(["tool:AllowedTool", "tool:SurfaceHiddenTool"])
1702        );
1703
1704        let surface_result = CapabilityDiscoveryIndex::from_snapshots(
1705            tools,
1706            &WorkflowCatalogSnapshot::default(),
1707            &WorkflowCatalogSnapshot::default(),
1708            &CapabilityDiscoveryEligibility {
1709                allowed_tool_names: Some(BTreeSet::from(["AllowedTool".to_string()])),
1710                ..Default::default()
1711            },
1712        )
1713        .discover(&request("inspect"))
1714        .expect("tool surface eligibility");
1715
1716        assert_eq!(surface_result.matches.len(), 1);
1717        assert_eq!(surface_result.matches[0].capability_ref, "tool:AllowedTool");
1718    }
1719
1720    #[test]
1721    fn unavailable_tool_metadata_and_skill_gateway_fail_closed() {
1722        let skill = catalog_entry(
1723            "hidden-skill",
1724            "Inspect Skill",
1725            "inspect",
1726            WorkflowKind::Instruction,
1727            WorkflowSource::User,
1728            1,
1729        );
1730        let eligibility = CapabilityDiscoveryEligibility {
1731            skill_gateway_available: false,
1732            ..Default::default()
1733        };
1734        let index = CapabilityDiscoveryIndex::from_snapshots(
1735            [
1736                ToolCapabilityMetadata {
1737                    canonical_name: "UnavailableTool".to_string(),
1738                    summary: "inspect".to_string(),
1739                    source: CapabilitySource::Custom,
1740                    aliases: Vec::new(),
1741                    available: false,
1742                },
1743                ToolCapabilityMetadata {
1744                    canonical_name: "AvailableTool".to_string(),
1745                    summary: "inspect".to_string(),
1746                    source: CapabilitySource::Custom,
1747                    aliases: Vec::new(),
1748                    available: true,
1749                },
1750            ],
1751            &WorkflowCatalogSnapshot {
1752                revision: 1,
1753                entries: vec![skill],
1754            },
1755            &WorkflowCatalogSnapshot::default(),
1756            &eligibility,
1757        );
1758        let result = index.discover(&request("inspect")).expect("fail closed");
1759
1760        assert_eq!(result.matches.len(), 1);
1761        assert_eq!(result.matches[0].capability_ref, "tool:AvailableTool");
1762    }
1763
1764    #[test]
1765    fn projection_excludes_schemas_paths_diagnostics_and_shadowed_metadata() {
1766        const SECRET: &str = "PRIVATE-DISCOVERY-SENTINEL";
1767        const PATH: &str = "/private/hidden/workflow.yaml";
1768        let schemas = [schema(
1769            "SafeTool",
1770            "Inspect safely",
1771            json!({"secret": SECRET, "path": PATH}),
1772        )];
1773        let mut skill = catalog_entry(
1774            "safe-skill",
1775            "Safe Skill",
1776            "Inspect safely",
1777            WorkflowKind::Instruction,
1778            WorkflowSource::Project,
1779            42,
1780        );
1781        skill.argument_schema = json!({"secret": SECRET, "path": PATH});
1782        skill.content_digest = SECRET.to_string();
1783        skill.last_error = Some(format!("{SECRET} at {PATH}"));
1784        skill.invocation_policy = json!({
1785            "explicit": true,
1786            "automatic": false,
1787            "credential": SECRET,
1788            "workspace_path": PATH,
1789            "input_schema": {"description": SECRET},
1790            "padding": "x".repeat(16_384)
1791        });
1792        skill.shadowed_candidates = vec![ShadowedWorkflowCandidate {
1793            source: WorkflowSource::Plugin,
1794            status: WorkflowStatus::Invalid,
1795            legacy: false,
1796            migration_status: None,
1797            last_error: Some(format!("{SECRET} at {PATH}")),
1798        }];
1799        let result = index(
1800            &schemas,
1801            vec![skill],
1802            Vec::new(),
1803            CapabilityDiscoveryEligibility::default(),
1804        )
1805        .discover(&request("inspect safely"))
1806        .expect("safe projection");
1807        let serialized = serde_json::to_string(&result).expect("serialize result");
1808
1809        assert!(!serialized.contains(SECRET));
1810        assert!(!serialized.contains(PATH));
1811        assert!(serialized.contains("\"revision\":42"));
1812        assert!(serialized.contains("\"source\":\"project\""));
1813        assert!(serialized.contains("\"invocation_policy\""));
1814        assert!(!serialized.contains("credential"));
1815        assert!(!serialized.contains("workspace_path"));
1816        assert!(!serialized.contains("input_schema"));
1817        assert!(serialized.len() < 2_048);
1818    }
1819
1820    #[test]
1821    fn tool_projection_keeps_exact_alias_shadow_separate_and_bounds_summaries() {
1822        let long = "x".repeat(MAX_CAPABILITY_SUMMARY_CHARS + 40);
1823        let metadata = project_tool_capability_metadata(&[
1824            schema("Edit", &long, json!({})),
1825            schema("apply_patch", "short alias", json!({})),
1826        ]);
1827        assert_eq!(metadata.len(), 2);
1828        let edit = metadata
1829            .iter()
1830            .find(|entry| entry.canonical_name == "Edit")
1831            .expect("builtin Edit projection");
1832        assert_eq!(edit.source, CapabilitySource::Builtin);
1833        assert_eq!(edit.aliases, vec!["apply_patch"]);
1834        let custom_alias = metadata
1835            .iter()
1836            .find(|entry| entry.canonical_name == "apply_patch")
1837            .expect("exact custom alias projection");
1838        assert_eq!(custom_alias.source, CapabilitySource::Custom);
1839        assert!(custom_alias.aliases.is_empty());
1840        let result = CapabilityDiscoveryIndex::from_snapshots(
1841            metadata,
1842            &WorkflowCatalogSnapshot::default(),
1843            &WorkflowCatalogSnapshot::default(),
1844            &CapabilityDiscoveryEligibility::default(),
1845        )
1846        .discover(&request("edit"))
1847        .expect("deduplicated");
1848
1849        assert_eq!(result.matches.len(), 1);
1850        assert_eq!(result.matches[0].capability_ref, "tool:Edit");
1851        assert!(result.matches[0].summary.chars().count() <= MAX_CAPABILITY_SUMMARY_CHARS);
1852    }
1853
1854    #[test]
1855    fn framework_source_and_alias_metadata_require_exact_registered_identity() {
1856        let metadata = project_tool_capability_metadata(&[
1857            schema("exec_command", "canonical builtin", json!({})),
1858            schema("Exec_Command", "custom mixed case", json!({})),
1859            schema("Project", "canonical server", json!({})),
1860            schema("browser", "server browser", json!({})),
1861            schema("project", "custom lowercase", json!({})),
1862            schema("Edit", "canonical edit", json!({})),
1863            schema("edit", "custom lowercase", json!({})),
1864            schema("mcp__alpha__inspect", "canonical mcp alias", json!({})),
1865            schema("MCP__alpha__inspect", "custom uppercase prefix", json!({})),
1866        ]);
1867        let entry = |name: &str| {
1868            metadata
1869                .iter()
1870                .find(|entry| entry.canonical_name == name)
1871                .unwrap_or_else(|| panic!("missing {name}"))
1872        };
1873
1874        assert_eq!(entry("exec_command").source, CapabilitySource::Builtin);
1875        assert_eq!(entry("Exec_Command").source, CapabilitySource::Custom);
1876        assert_eq!(entry("Project").source, CapabilitySource::Server);
1877        assert_eq!(entry("browser").source, CapabilitySource::Server);
1878        assert_eq!(entry("project").source, CapabilitySource::Custom);
1879        assert_eq!(entry("Edit").aliases, vec!["apply_patch"]);
1880        assert!(entry("edit").aliases.is_empty());
1881        assert_eq!(entry("mcp__alpha__inspect").source, CapabilitySource::Mcp);
1882        assert_eq!(
1883            entry("MCP__alpha__inspect").source,
1884            CapabilitySource::Custom
1885        );
1886    }
1887
1888    #[test]
1889    fn raw_and_classified_projections_exclude_host_only_and_keep_deferred_tools() {
1890        let schemas = [
1891            schema("exec_command", "inspect core", json!({})),
1892            schema("Glob", "inspect deferred", json!({})),
1893            schema("custom_tool", "inspect custom", json!({})),
1894            schema("mcp__alpha__inspect", "inspect mcp", json!({})),
1895            schema("Workspace", "inspect host", json!({})),
1896            schema("request_permissions", "inspect host", json!({})),
1897        ];
1898        let raw = project_tool_capability_metadata(&schemas);
1899        let classified = schemas
1900            .iter()
1901            .cloned()
1902            .filter_map(ClassifiedToolSchema::new)
1903            .collect::<Vec<_>>();
1904        let typed = project_classified_tool_capability_metadata(&classified);
1905        let names = |items: &[ToolCapabilityMetadata]| {
1906            items
1907                .iter()
1908                .map(|item| item.canonical_name.clone())
1909                .collect::<BTreeSet<_>>()
1910        };
1911
1912        assert_eq!(names(&raw), names(&typed));
1913        assert_eq!(
1914            names(&raw),
1915            BTreeSet::from([
1916                "exec_command".to_string(),
1917                "Glob".to_string(),
1918                "custom_tool".to_string(),
1919                "mcp__alpha__inspect".to_string(),
1920            ])
1921        );
1922    }
1923
1924    #[test]
1925    fn snapshot_boundary_rejects_forged_host_only_metadata() {
1926        let mut metadata = [
1927            "Workspace",
1928            "request_permissions",
1929            "GetCurrentDir",
1930            "SetWorkspace",
1931        ]
1932        .into_iter()
1933        .map(|name| ToolCapabilityMetadata {
1934            canonical_name: name.to_string(),
1935            summary: "inspect host".to_string(),
1936            source: CapabilitySource::Custom,
1937            aliases: Vec::new(),
1938            available: true,
1939        })
1940        .collect::<Vec<_>>();
1941        metadata.push(ToolCapabilityMetadata {
1942            canonical_name: "VisibleTool".to_string(),
1943            summary: "inspect visible".to_string(),
1944            source: CapabilitySource::Custom,
1945            aliases: Vec::new(),
1946            available: true,
1947        });
1948        let result = CapabilityDiscoveryIndex::from_snapshots(
1949            metadata,
1950            &WorkflowCatalogSnapshot::default(),
1951            &WorkflowCatalogSnapshot::default(),
1952            &CapabilityDiscoveryEligibility::default(),
1953        )
1954        .discover(&request("inspect"))
1955        .expect("host-only boundary");
1956
1957        assert_eq!(result.matches.len(), 1);
1958        assert_eq!(result.matches[0].capability_ref, "tool:VisibleTool");
1959    }
1960
1961    #[test]
1962    fn disabled_aliases_use_the_same_canonical_identity_as_discovery() {
1963        let eligibility = CapabilityDiscoveryEligibility {
1964            disabled_tool_names: BTreeSet::from([
1965                "apply_patch".to_string(),
1966                "FileExists".to_string(),
1967                "sub_session_manager".to_string(),
1968            ]),
1969            ..Default::default()
1970        };
1971        let result = index(
1972            &[
1973                schema("Edit", "inspect edit", json!({})),
1974                schema("GetFileInfo", "inspect file", json!({})),
1975                schema("SubAgent", "inspect agent", json!({})),
1976                schema("VisibleTool", "inspect visible", json!({})),
1977            ],
1978            Vec::new(),
1979            Vec::new(),
1980            eligibility,
1981        )
1982        .discover(&request("inspect"))
1983        .expect("canonical disabled filter");
1984
1985        assert_eq!(result.matches.len(), 1);
1986        assert_eq!(result.matches[0].capability_ref, "tool:VisibleTool");
1987    }
1988
1989    #[test]
1990    fn eligibility_resolves_exact_shadow_before_alias_fallback() {
1991        let schemas = [
1992            schema("Edit", "builtin edit", json!({})),
1993            schema("apply_patch", "custom exact patch", json!({})),
1994        ];
1995        let allowed_builtin = index(
1996            &schemas,
1997            Vec::new(),
1998            Vec::new(),
1999            CapabilityDiscoveryEligibility {
2000                allowed_tool_names: Some(BTreeSet::from(["Edit".to_string()])),
2001                ..Default::default()
2002            },
2003        )
2004        .discover(&request("patch"))
2005        .expect("exact allowed builtin");
2006        assert_eq!(allowed_builtin.matches.len(), 1);
2007        assert_eq!(allowed_builtin.matches[0].capability_ref, "tool:Edit");
2008
2009        let allowed_shadow = index(
2010            &schemas,
2011            Vec::new(),
2012            Vec::new(),
2013            CapabilityDiscoveryEligibility {
2014                allowed_tool_names: Some(BTreeSet::from(["apply_patch".to_string()])),
2015                ..Default::default()
2016            },
2017        )
2018        .discover(&request("patch"))
2019        .expect("exact allowed custom shadow");
2020        assert_eq!(allowed_shadow.matches.len(), 1);
2021        assert_eq!(allowed_shadow.matches[0].capability_ref, "tool:apply_patch");
2022
2023        let alias_fallback = index(
2024            &[schema("Edit", "builtin edit", json!({}))],
2025            Vec::new(),
2026            Vec::new(),
2027            CapabilityDiscoveryEligibility {
2028                allowed_tool_names: Some(BTreeSet::from(["apply_patch".to_string()])),
2029                ..Default::default()
2030            },
2031        )
2032        .discover(&request("patch"))
2033        .expect("unshadowed alias fallback");
2034        assert_eq!(alias_fallback.matches.len(), 1);
2035        assert_eq!(alias_fallback.matches[0].capability_ref, "tool:Edit");
2036
2037        let disabled_shadow = index(
2038            &schemas,
2039            Vec::new(),
2040            Vec::new(),
2041            CapabilityDiscoveryEligibility {
2042                disabled_tool_names: BTreeSet::from(["apply_patch".to_string()]),
2043                ..Default::default()
2044            },
2045        )
2046        .discover(&request("edit"))
2047        .expect("exact disabled custom shadow");
2048        assert_eq!(disabled_shadow.matches.len(), 1);
2049        assert_eq!(disabled_shadow.matches[0].capability_ref, "tool:Edit");
2050    }
2051
2052    #[test]
2053    fn case_distinct_dynamic_and_mcp_identities_do_not_collapse() {
2054        let metadata = project_tool_capability_metadata(&[
2055            schema("Foo", "inspect custom", json!({})),
2056            schema("foo", "inspect custom", json!({})),
2057            schema("mcp__Alpha__inspect", "inspect mcp", json!({})),
2058            schema("mcp__alpha__inspect", "inspect mcp", json!({})),
2059        ]);
2060        assert_eq!(metadata.len(), 4);
2061
2062        let eligibility = CapabilityDiscoveryEligibility {
2063            disabled_tool_names: BTreeSet::from(["mcp__Alpha__inspect".to_string()]),
2064            ..Default::default()
2065        };
2066        let result = CapabilityDiscoveryIndex::from_snapshots(
2067            metadata,
2068            &WorkflowCatalogSnapshot::default(),
2069            &WorkflowCatalogSnapshot::default(),
2070            &eligibility,
2071        )
2072        .discover(&request("inspect"))
2073        .expect("case-preserving discovery");
2074        let refs = result
2075            .matches
2076            .iter()
2077            .map(|entry| entry.capability_ref.as_str())
2078            .collect::<BTreeSet<_>>();
2079
2080        assert_eq!(refs.len(), 3);
2081        assert!(refs.contains("tool:Foo"));
2082        assert!(refs.contains("tool:foo"));
2083        assert!(refs.contains("tool:mcp__alpha__inspect"));
2084        assert!(!refs.contains("tool:mcp__Alpha__inspect"));
2085
2086        let allowed = CapabilityDiscoveryIndex::from_snapshots(
2087            project_tool_capability_metadata(&[
2088                schema("Foo", "inspect custom", json!({})),
2089                schema("foo", "inspect custom", json!({})),
2090                schema("mcp__Alpha__inspect", "inspect mcp", json!({})),
2091                schema("mcp__alpha__inspect", "inspect mcp", json!({})),
2092            ]),
2093            &WorkflowCatalogSnapshot::default(),
2094            &WorkflowCatalogSnapshot::default(),
2095            &CapabilityDiscoveryEligibility {
2096                allowed_tool_names: Some(BTreeSet::from([
2097                    "Foo".to_string(),
2098                    "mcp__alpha__inspect".to_string(),
2099                ])),
2100                ..Default::default()
2101            },
2102        )
2103        .discover(&request("inspect"))
2104        .expect("case-preserving allowlist");
2105        let allowed_refs = allowed
2106            .matches
2107            .iter()
2108            .map(|entry| entry.capability_ref.as_str())
2109            .collect::<BTreeSet<_>>();
2110        assert_eq!(
2111            allowed_refs,
2112            BTreeSet::from(["tool:Foo", "tool:mcp__alpha__inspect"])
2113        );
2114    }
2115
2116    #[test]
2117    fn ordinary_discover_named_function_is_searchable_and_disableable() {
2118        let schemas = [schema(
2119            "discover",
2120            "discover a custom data source",
2121            json!({}),
2122        )];
2123        let visible = index(
2124            &schemas,
2125            Vec::new(),
2126            Vec::new(),
2127            CapabilityDiscoveryEligibility::default(),
2128        )
2129        .discover(&request("discover custom"))
2130        .expect("ordinary discover-named function");
2131        assert_eq!(visible.matches.len(), 1);
2132        assert_eq!(visible.matches[0].capability_ref, "tool:discover");
2133
2134        let hidden = index(
2135            &schemas,
2136            Vec::new(),
2137            Vec::new(),
2138            CapabilityDiscoveryEligibility {
2139                disabled_tool_names: BTreeSet::from(["discover".to_string()]),
2140                ..Default::default()
2141            },
2142        )
2143        .discover(&request("discover custom"))
2144        .expect("disabled ordinary function");
2145        assert!(hidden.matches.is_empty());
2146    }
2147
2148    #[tokio::test]
2149    async fn store_facade_builds_from_atomic_metadata_snapshots() {
2150        let store = crate::SkillStore::default();
2151        let catalog = [schema(
2152            "SafeTool",
2153            "Inspect safely",
2154            json!({"private_schema": "must-not-be-read"}),
2155        )]
2156        .into_iter()
2157        .filter_map(ClassifiedToolSchema::new)
2158        .collect::<Vec<_>>();
2159        let index = CapabilityDiscoveryIndex::from_resolved_classified_store(
2160            &catalog,
2161            &store,
2162            &CapabilityDiscoveryEligibility::default(),
2163        )
2164        .await;
2165
2166        let result = index
2167            .discover(&request("inspect safely"))
2168            .expect("metadata-only store projection");
2169        assert_eq!(result.matches.len(), 1);
2170        assert_eq!(result.matches[0].capability_ref, "tool:SafeTool");
2171
2172        let edit_schema = schema("Edit", "Edit safely", json!({}));
2173        let resolved_catalog = [edit_schema.clone()]
2174            .into_iter()
2175            .filter_map(ClassifiedToolSchema::new)
2176            .collect::<Vec<_>>();
2177        let disabled_alias = CapabilityDiscoveryEligibility {
2178            disabled_tool_names: BTreeSet::from(["apply_patch".to_string()]),
2179            ..Default::default()
2180        };
2181        let resolved = CapabilityDiscoveryIndex::from_resolved_classified_store(
2182            &resolved_catalog,
2183            &store,
2184            &disabled_alias,
2185        )
2186        .await
2187        .discover(&request("edit"))
2188        .expect("resolved catalogs must not be filtered twice");
2189        assert_eq!(resolved.matches[0].capability_ref, "tool:Edit");
2190
2191        let legacy = CapabilityDiscoveryIndex::from_store(&[edit_schema], &store, &disabled_alias)
2192            .await
2193            .discover(&request("edit"))
2194            .expect("raw schemas still resolve tool eligibility");
2195        assert!(legacy.matches.is_empty());
2196    }
2197    #[test]
2198    fn automatic_decisions_explain_exact_weak_ambiguous_and_missing_matches() {
2199        let entries = vec![
2200            catalog_entry(
2201                "review-a",
2202                "Review Alpha",
2203                "review code changes",
2204                WorkflowKind::Instruction,
2205                WorkflowSource::User,
2206                7,
2207            ),
2208            catalog_entry(
2209                "review-b",
2210                "Review Beta",
2211                "review code changes",
2212                WorkflowKind::Instruction,
2213                WorkflowSource::User,
2214                8,
2215            ),
2216        ];
2217        let search = index(
2218            &[],
2219            entries,
2220            vec![],
2221            CapabilityDiscoveryEligibility {
2222                skill_invocation: InvocationEligibility::Automatic,
2223                ..Default::default()
2224            },
2225        );
2226        for (query, expected) in [
2227            ("", AutomaticSkillOutcome::EmptyQuery),
2228            ("review-a", AutomaticSkillOutcome::ExactMatch),
2229            ("review", AutomaticSkillOutcome::BelowThreshold),
2230            (
2231                "please review code changes",
2232                AutomaticSkillOutcome::Ambiguous,
2233            ),
2234            ("request-text-marker", AutomaticSkillOutcome::NoMatch),
2235        ] {
2236            let decision = search.explain_automatic_skill(query);
2237            assert_eq!(decision.outcome, expected, "{query}");
2238            assert!(decision.candidates.len() <= 3);
2239            assert_eq!(
2240                decision.selected,
2241                search.discover_unambiguous_automatic_skill(query)
2242            );
2243            assert_eq!(decision, search.explain_automatic_skill(query));
2244        }
2245        let exact = search.explain_automatic_skill("review-a");
2246        assert_eq!(exact.selected.unwrap().revision, Some(7));
2247        let encoded =
2248            serde_json::to_string(&search.explain_automatic_skill("request-text-marker")).unwrap();
2249        assert!(!encoded.contains("request-text-marker"));
2250    }
2251
2252    #[test]
2253    fn decision_candidates_respect_disabled_and_shadowed_catalog_entries() {
2254        let mut shadow = catalog_entry(
2255            "shadow",
2256            "Review",
2257            "review code",
2258            WorkflowKind::Instruction,
2259            WorkflowSource::Builtin,
2260            1,
2261        );
2262        shadow.winner = false;
2263        let disabled = catalog_entry(
2264            "disabled",
2265            "Review",
2266            "review code",
2267            WorkflowKind::Instruction,
2268            WorkflowSource::User,
2269            2,
2270        );
2271        let search = index(
2272            &[],
2273            vec![shadow, disabled],
2274            vec![],
2275            CapabilityDiscoveryEligibility {
2276                disabled_skill_ids: BTreeSet::from(["disabled".into()]),
2277                ..Default::default()
2278            },
2279        );
2280        let decision = search.explain_automatic_skill("review code");
2281        assert_eq!(decision.outcome, AutomaticSkillOutcome::NoMatch);
2282        assert!(decision.candidates.is_empty());
2283        assert!(decision.selected.is_none());
2284    }
2285}