Preserve discovery paths for symlinked skills (#37144)

## Why

Symlinked skills have a canonical `SKILL.md` path that can differ from the path
under the configured skill root. A catalog entry that advertises the discovery
path must still select the canonical skill.

## What changed

- Track each loaded skill's discovery path alongside its canonical identity.
- Render discovery paths in the skill catalog and accept either form for
  structured selections and linked skill mentions.
- Preserve enabled-state checks, deduplication, and product filtering when
  resolving discovery paths.

## Testing

Added loader, catalog, mention-resolution, and end-to-end turn coverage for
skills discovered through directory symlinks.

GitOrigin-RevId: bae539b766cc3782c7c121a4276c337699cbb509
This commit is contained in:
xl-openai authored and copyberry committed 2026-08-05 19:51:51 +00:00
1 parent fa5d5ae047
commit 72d937ed4d
11 files changed
+392 -49

No files matched your search

+5
View File
@@ -106,6 +106,11 @@ fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog {
for (skill, enabled) in outcome.skills_with_enabled() {
let mut entry = catalog_entry_from_skill(skill, enabled);
if let Some(discovery_path) =
outcome.skill_discovery_path_for_path(&skill.path_to_skills_md)
{
entry = entry.with_display_path(discovery_path.to_string_lossy().replace('\\', "/"));
}
if let Some(root) = outcome.skill_root_for_path(&skill.path_to_skills_md) {
entry = entry.with_display_path_root(root.to_string_lossy().replace('\\', "/"));
if let Some(root_order) = root_order_by_path.get(root.as_path()) {
@@ -63,3 +63,53 @@ async fn host_catalog_entries_carry_their_render_metadata() -> Result<(), Box<dy
std::fs::remove_dir_all(root.as_path())?;
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn host_catalog_preserves_symlinked_skill_discovery_paths()
-> Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let source = tempfile::tempdir()?;
let source_skill_dir = source.path().join("linked-skill");
std::fs::create_dir_all(&source_skill_dir)?;
std::fs::write(
source_skill_dir.join("SKILL.md"),
"---\nname: linked-skill\ndescription: Linked skill.\n---\n# Linked skill\n",
)?;
std::os::unix::fs::symlink(&source_skill_dir, root.path().join("linked-skill"))?;
let root = AbsolutePathBuf::try_from(std::fs::canonicalize(root.path())?)?;
let outcome = load_skills_from_roots(
[SkillRoot {
path: root.clone(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(1)),
)
.await;
let catalog = catalog_from_outcome(&outcome);
let canonical_path = std::fs::canonicalize(source_skill_dir.join("SKILL.md"))?;
let discovery_path = root.join("linked-skill/SKILL.md");
assert_eq!(catalog.entries.len(), 1);
assert_eq!(
(
catalog.entries[0].main_prompt.as_str(),
catalog.entries[0].display_path.as_deref(),
catalog.entries[0].display_path_root(),
),
(
canonical_path.to_string_lossy().as_ref(),
Some(discovery_path.to_string_lossy().as_ref()),
Some(root.to_string_lossy().as_ref()),
)
);
Ok(())
}