From a1187b141c457bd04879eb8c1f5c04e1f1ee3b68 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Thu, 28 May 2026 16:35:01 -0700 Subject: [PATCH 1/4] Resolve remote skill locations in tool output Co-Authored-By: Oz --- app/src/ai/blocklist/block/view_impl.rs | 1 + .../ai/blocklist/block/view_impl/output.rs | 66 ++++++++----- .../blocklist/block/view_impl/output_tests.rs | 93 ++++++++++++++++++- app/src/ai/skills/mod.rs | 2 +- app/src/ai/skills/skill_utils.rs | 39 +------- app/src/ai/skills/skill_utils_tests.rs | 52 ----------- 6 files changed, 139 insertions(+), 114 deletions(-) diff --git a/app/src/ai/blocklist/block/view_impl.rs b/app/src/ai/blocklist/block/view_impl.rs index 1b37bdca7a..0716883f98 100644 --- a/app/src/ai/blocklist/block/view_impl.rs +++ b/app/src/ai/blocklist/block/view_impl.rs @@ -1060,6 +1060,7 @@ impl View for AIBlock { action_buttons: &self.action_buttons, view_screenshot_buttons: &self.view_screenshot_buttons, action_model: &self.action_model, + active_session: &self.active_session, editor_views: &self.code_editor_views, current_working_directory: self.current_working_directory.as_ref(), shell_launch_data: self.shell_launch_data.as_ref(), diff --git a/app/src/ai/blocklist/block/view_impl/output.rs b/app/src/ai/blocklist/block/view_impl/output.rs index 870410f7c6..59c5e88876 100644 --- a/app/src/ai/blocklist/block/view_impl/output.rs +++ b/app/src/ai/blocklist/block/view_impl/output.rs @@ -5,7 +5,7 @@ use std::cell::OnceCell; use std::cmp::Ordering; use std::collections::HashMap; #[allow(unused_imports)] -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Path}; use std::rc::Rc; use std::sync::Arc; @@ -23,8 +23,6 @@ use ui_components::{button, Component as _, Options as _}; use warp_core::channel::ChannelState; use warp_core::ui::theme::color::internal_colors; use warp_util::local_or_remote_path::LocalOrRemotePath; -#[allow(unused_imports)] -use warp_util::path::{common_path, CleanPathResult}; use warpui::elements::new_scrollable::SingleAxisConfig; use warpui::elements::{ Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, @@ -103,13 +101,14 @@ use crate::ai::blocklist::view_util::format_credits; use crate::ai::blocklist::{AIBlockResponseRating, BlocklistAIActionModel, SuggestionChipView}; use crate::ai::paths::shell_native_absolute_path; use crate::ai::skills::{ - icon_override_for_skill_name, render_skill_button, skill_path_from_file_path, SkillManager, + icon_override_for_skill_name, render_skill_button, skill_path_from_location, SkillManager, SkillOpenOrigin, }; use crate::appearance::Appearance; use crate::code::diff_viewer::DisplayMode; use crate::code::editor_management::CodeSource; use crate::settings_view::SettingsSection; +use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::shared_session::SharedSessionStatus; use crate::terminal::ShellLaunchData; use crate::ui_components::blended_colors; @@ -134,6 +133,7 @@ pub(crate) struct Props<'a> { pub(super) action_buttons: &'a HashMap, pub(super) view_screenshot_buttons: &'a HashMap, pub(crate) action_model: &'a ModelHandle, + pub(crate) active_session: &'a ModelHandle, pub(super) editor_views: &'a [EmbeddedCodeEditorView], pub(super) current_working_directory: Option<&'a String>, pub(super) shell_launch_data: Option<&'a ShellLaunchData>, @@ -488,14 +488,23 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box { .collect_vec(), }; - let file_paths = files.iter().map(|file| { - PathBuf::from(shell_native_absolute_path( - &file.name, - props.shell_launch_data, - props.current_working_directory, - )) + let file_locations = files + .iter() + .map(|file| { + let path = shell_native_absolute_path( + &file.name, + props.shell_launch_data, + props.current_working_directory, + ); + props + .active_session + .as_ref(app) + .location_for_path(&path, app) + }) + .collect::>>(); + let skill = file_locations.and_then(|file_locations| { + parsed_skill_for_common_locations(file_locations, app) }); - let skill = parsed_skill_for_common_local_paths(file_paths, app); output_items.add_child(render_read_files( props, id, @@ -1494,9 +1503,18 @@ fn render_search_codebase( .render(app) .finish() } else { - let file_paths = - files.iter().map(|file| PathBuf::from(&file.file_name)); - let skill = parsed_skill_for_common_local_paths(file_paths, app); + let file_locations = files + .iter() + .map(|file| { + props + .active_session + .as_ref(app) + .location_for_path(&file.file_name, app) + }) + .collect::>>(); + let skill = file_locations.and_then(|file_locations| { + parsed_skill_for_common_locations(file_locations, app) + }); let grouped = group_file_contexts_for_display(files, None, None); return Some(render_read_files( props, @@ -1865,16 +1883,20 @@ fn render_read_files( renderable_action.render(app).finish() } -fn parsed_skill_for_common_local_paths( - file_paths: impl IntoIterator, +fn parsed_skill_for_common_locations( + file_locations: impl IntoIterator, app: &AppContext, ) -> Option<&ai::skills::ParsedSkill> { - let file_paths = file_paths.into_iter().collect_vec(); - common_path(&file_paths) - .and_then(|common| skill_path_from_file_path(&common)) - .and_then(|skill_path| { - SkillManager::as_ref(app).skill_by_path(&LocalOrRemotePath::Local(skill_path)) - }) + let skill_paths = file_locations + .into_iter() + .map(|location| skill_path_from_location(&location)) + .collect::>>()?; + let first_skill_path = skill_paths.first()?; + skill_paths + .iter() + .all(|skill_path| skill_path == first_skill_path) + .then(|| SkillManager::as_ref(app).skill_by_path(first_skill_path)) + .flatten() } fn maybe_render_edit_document( diff --git a/app/src/ai/blocklist/block/view_impl/output_tests.rs b/app/src/ai/blocklist/block/view_impl/output_tests.rs index d0133cc5a7..3b3312d108 100644 --- a/app/src/ai/blocklist/block/view_impl/output_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/output_tests.rs @@ -1,7 +1,19 @@ use ai::agent::action::UploadArtifactRequest; +use ai::skills::{ParsedSkill, SkillProvider, SkillScope}; +use repo_metadata::repositories::DetectedRepositories; +use repo_metadata::{DirectoryWatcher, RepoMetadataModel}; +use warp_util::{ + host_id::HostId, local_or_remote_path::LocalOrRemotePath, remote_path::RemotePath, + standardized_path::StandardizedPath, +}; +use warpui::App; +use watcher::HomeDirectoryWatcher; -use super::format_upload_artifact_text; +use super::{format_upload_artifact_text, parsed_skill_for_common_locations}; use crate::ai::agent::UploadArtifactResult; +use crate::ai::skills::SkillManager; +use crate::settings::AISettings; +use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; #[test] fn format_upload_artifact_text_includes_request_details() { @@ -62,3 +74,82 @@ fn format_upload_artifact_text_includes_terminal_status() { format_upload_artifact_text(&request, Some(&UploadArtifactResult::Cancelled)); assert_eq!(cancelled_text, "Upload artifact: reports/daily.txt"); } + +fn remote_location(host_id: &HostId, path: &str) -> LocalOrRemotePath { + LocalOrRemotePath::Remote(RemotePath::new( + host_id.clone(), + StandardizedPath::try_new(path).unwrap(), + )) +} + +#[test] +fn parsed_skill_for_common_locations_resolves_cached_remote_skill() { + let host_id = HostId::new("remote-host".to_string()); + let skill = ParsedSkill { + name: "deploy".to_string(), + description: "Deploy skill".to_string(), + path: remote_location(&host_id, "/repo/.agents/skills/deploy/SKILL.md"), + content: "# Deploy".to_string(), + line_range: None, + provider: SkillProvider::Agents, + scope: SkillScope::Project, + }; + let locations = vec![ + remote_location(&host_id, "/repo/.agents/skills/deploy/README.md"), + remote_location(&host_id, "/repo/.agents/skills/deploy/scripts/run.sh"), + ]; + + App::test((), |mut app| async move { + app.add_singleton_model(DirectoryWatcher::new); + app.add_singleton_model(AISettings::new_with_defaults); + app.add_singleton_model(|_| DetectedRepositories::default()); + app.add_singleton_model(RepoMetadataModel::new); + app.add_singleton_model(HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing); + let manager = app.add_singleton_model(SkillManager::new); + manager.update(&mut app, |manager, _| { + manager.add_skill_for_testing(skill.clone()); + }); + + let resolved = manager.read(&app, |_, ctx| { + parsed_skill_for_common_locations(locations, ctx).map(|skill| skill.path.clone()) + }); + assert_eq!(resolved, Some(skill.path)); + }); +} + +#[test] +fn parsed_skill_for_common_locations_does_not_mix_remote_hosts() { + let first_host = HostId::new("first-host".to_string()); + let second_host = HostId::new("second-host".to_string()); + let skill = ParsedSkill { + name: "deploy".to_string(), + description: "Deploy skill".to_string(), + path: remote_location(&first_host, "/repo/.agents/skills/deploy/SKILL.md"), + content: "# Deploy".to_string(), + line_range: None, + provider: SkillProvider::Agents, + scope: SkillScope::Project, + }; + let locations = vec![ + remote_location(&first_host, "/repo/.agents/skills/deploy/README.md"), + remote_location(&second_host, "/repo/.agents/skills/deploy/README.md"), + ]; + + App::test((), |mut app| async move { + app.add_singleton_model(DirectoryWatcher::new); + app.add_singleton_model(AISettings::new_with_defaults); + app.add_singleton_model(|_| DetectedRepositories::default()); + app.add_singleton_model(RepoMetadataModel::new); + app.add_singleton_model(HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing); + let manager = app.add_singleton_model(SkillManager::new); + manager.update(&mut app, |manager, _| { + manager.add_skill_for_testing(skill); + }); + + assert!(manager.read(&app, |_, ctx| { + parsed_skill_for_common_locations(locations, ctx).is_none() + })); + }); +} diff --git a/app/src/ai/skills/mod.rs b/app/src/ai/skills/mod.rs index 80cf5c9887..45ff36f314 100644 --- a/app/src/ai/skills/mod.rs +++ b/app/src/ai/skills/mod.rs @@ -24,7 +24,7 @@ pub use listed_skill::SkillDescriptor; mod skill_utils; pub use skill_utils::{ icon_override_for_skill_name, list_skills_if_changed, render_skill_button, - skill_path_from_file_path, skill_path_from_location, + skill_path_from_location, }; pub trait SkillPathQuery { fn to_skill_location(&self) -> LocalOrRemotePath; diff --git a/app/src/ai/skills/skill_utils.rs b/app/src/ai/skills/skill_utils.rs index 657947c25e..1b6439aba0 100644 --- a/app/src/ai/skills/skill_utils.rs +++ b/app/src/ai/skills/skill_utils.rs @@ -3,11 +3,8 @@ use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; -use std::path::{Path, PathBuf}; -use ai::skills::{ - home_skills_path, provider_rank, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS, -}; +use ai::skills::{provider_rank, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS}; use lazy_static::lazy_static; use siphasher::sip::SipHasher; use warp_core::ui::appearance::Appearance; @@ -21,7 +18,6 @@ use super::{SkillDescriptor, SkillManager}; use crate::ai::agent::conversation::AIConversationId; use crate::ai::blocklist::view_util::render_provider_icon_button; use crate::ai::blocklist::BlocklistAIHistoryModel; -use crate::warp_managed_paths_watcher::warp_managed_skill_dirs; lazy_static! { static ref CONTENT_HASHER: SipHasher = SipHasher::new_with_keys(0, 0); @@ -181,39 +177,6 @@ pub fn skill_path_from_location(location: &LocalOrRemotePath) -> Option Option { - for definition in SKILL_PROVIDER_DEFINITIONS.iter() { - let home_skill_dirs = if definition.provider == SkillProvider::Warp { - warp_managed_skill_dirs() - } else { - home_skills_path(definition.provider).into_iter().collect() - }; - for home_skills_path in home_skill_dirs { - if let Ok(relative_path) = file_path.strip_prefix(&home_skills_path) { - let skill_name = relative_path.components().next()?; - return Some(home_skills_path.join(skill_name).join("SKILL.md")); - } - } - } - let path_components: Vec<_> = file_path.components().collect(); - - for def in SKILL_PROVIDER_DEFINITIONS.iter() { - let skill_components: Vec<_> = def.skills_path.components().collect(); - - for (idx, window) in path_components.windows(skill_components.len()).enumerate() { - if window == skill_components.as_slice() { - let skill_dir = PathBuf::from_iter( - file_path - .components() - .take(idx + skill_components.len() + 1), - ); - return Some(skill_dir.join("SKILL.md")); - } - } - } - None -} - #[cfg(test)] #[path = "skill_utils_tests.rs"] mod tests; diff --git a/app/src/ai/skills/skill_utils_tests.rs b/app/src/ai/skills/skill_utils_tests.rs index a22c55fac8..0e424e52d9 100644 --- a/app/src/ai/skills/skill_utils_tests.rs +++ b/app/src/ai/skills/skill_utils_tests.rs @@ -5,58 +5,6 @@ use warp_util::local_or_remote_path::LocalOrRemotePath; use super::*; -#[test] -fn test_skill_path_from_file_path_skill_md() { - let skill = PathBuf::from("/home/user/.claude/skills/my-skill/SKILL.md"); - let result = skill_path_from_file_path(&skill); - assert_eq!( - result, - Some(PathBuf::from("/home/user/.claude/skills/my-skill/SKILL.md")) - ); -} - -#[test] -fn test_skill_path_from_file_path_warp_home_skill() { - let Some(warp_home_skills_dir) = warp_core::paths::warp_home_skills_dir() else { - eprintln!("Skipping test: Warp home skills directory not available"); - return; - }; - let warp_home_skill = warp_home_skills_dir - .join("my-skill") - .join("assets") - .join("image.png"); - let result = skill_path_from_file_path(&warp_home_skill); - assert_eq!( - result, - Some(warp_home_skills_dir.join("my-skill").join("SKILL.md")) - ); -} - -#[test] -fn test_skill_path_from_file_path_nested_file() { - let skill_nested = PathBuf::from("/home/user/.agents/skills/my-skill/assets/image.png"); - let result = skill_path_from_file_path(&skill_nested); - assert_eq!( - result, - Some(PathBuf::from("/home/user/.agents/skills/my-skill/SKILL.md")) - ); -} - -#[test] -fn test_skill_path_from_file_path_non_skill() { - let non_skill = PathBuf::from("/home/user/Documents/file.txt"); - let result = skill_path_from_file_path(&non_skill); - assert_eq!(result, None); - - let similar_path = PathBuf::from("/home/user/.claude/other/file.txt"); - let result = skill_path_from_file_path(&similar_path); - assert_eq!(result, None); - - let empty_path = PathBuf::from(""); - let result = skill_path_from_file_path(&empty_path); - assert_eq!(result, None); -} - #[test] fn test_unique_skills_dedupes_identical_skills_same_dir() { let shared_skill_dir = PathBuf::from("/home/user"); From 353b1d01fec9e9e778cbc853ec0de7317e8d2ab1 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Fri, 29 May 2026 18:46:17 -0700 Subject: [PATCH 2/4] wasm --- app/src/warp_managed_paths_watcher.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/warp_managed_paths_watcher.rs b/app/src/warp_managed_paths_watcher.rs index 245c418157..db16e7f6ce 100644 --- a/app/src/warp_managed_paths_watcher.rs +++ b/app/src/warp_managed_paths_watcher.rs @@ -51,6 +51,7 @@ pub(crate) fn warp_home_config_dir() -> Option { warp_core::paths::warp_home_config_dir() } +#[cfg_attr(target_family = "wasm", allow(dead_code))] pub(crate) fn warp_home_skills_dir() -> Option { warp_core::paths::warp_home_skills_dir() } @@ -67,6 +68,7 @@ pub(crate) struct WarpMcpConfigPath { pub(crate) config_path: PathBuf, } +#[cfg_attr(target_family = "wasm", allow(dead_code))] pub(crate) fn warp_managed_skill_dirs() -> Vec { warp_home_skills_dir().into_iter().collect() } From 8ad69d3a1cc3a97c9a9aaea069ecfe8faa9c9c7a Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Sat, 30 May 2026 22:44:11 -0700 Subject: [PATCH 3/4] share helper Co-Authored-By: Oz --- app/src/ai/skills/file_watchers/utils.rs | 30 ++------- app/src/ai/skills/skill_utils.rs | 18 +++--- app/src/ai/skills/skill_utils_tests.rs | 31 ++++++++- crates/ai/src/skills/mod.rs | 4 +- crates/ai/src/skills/skill_provider.rs | 80 +++++++++++++++++++----- 5 files changed, 108 insertions(+), 55 deletions(-) diff --git a/app/src/ai/skills/file_watchers/utils.rs b/app/src/ai/skills/file_watchers/utils.rs index 7cb022cd9d..cc9c715fdd 100644 --- a/app/src/ai/skills/file_watchers/utils.rs +++ b/app/src/ai/skills/file_watchers/utils.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; use ai::skills::{ - home_skills_path, parse_skill, read_skills, ParsedSkill, SkillProvider, - SKILL_PROVIDER_DEFINITIONS, + home_skills_path, parse_skill, provider_parent_directory_for_skills_root, read_skills, + ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS, }; use anyhow::Error; use repo_metadata::local_model::GetContentsArgs; @@ -224,30 +224,8 @@ pub fn extract_skill_parent_directory( return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path())); }; - for definition in SKILL_PROVIDER_DEFINITIONS.iter() { - let mut parent_directory = skills_root.clone(); - let mut matches_provider = true; - for component in definition.skills_path.components().rev() { - let Some(expected_component) = component.as_os_str().to_str() else { - matches_provider = false; - break; - }; - if parent_directory.file_name() != Some(expected_component) { - matches_provider = false; - break; - } - let Some(parent) = parent_directory.parent() else { - matches_provider = false; - break; - }; - parent_directory = parent; - } - if matches_provider { - return Ok(parent_directory); - } - } - - Err(anyhow::anyhow!("Not a skill path: {}", path.display_path())) + provider_parent_directory_for_skills_root(&skills_root) + .ok_or_else(|| anyhow::anyhow!("Not a skill path: {}", path.display_path())) } /// Check if this path is a skill directory under a home directory provider path diff --git a/app/src/ai/skills/skill_utils.rs b/app/src/ai/skills/skill_utils.rs index 1b6439aba0..e679762343 100644 --- a/app/src/ai/skills/skill_utils.rs +++ b/app/src/ai/skills/skill_utils.rs @@ -4,7 +4,9 @@ use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; -use ai::skills::{provider_rank, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS}; +use ai::skills::{ + provider_parent_directory_for_skills_root, provider_rank, ParsedSkill, SkillProvider, +}; use lazy_static::lazy_static; use siphasher::sip::SipHasher; use warp_core::ui::appearance::Appearance; @@ -163,14 +165,12 @@ pub fn icon_override_for_skill_name(name: &str) -> Option { pub fn skill_path_from_location(location: &LocalOrRemotePath) -> Option { let mut current = Some(location.clone()); while let Some(candidate_skill_dir) = current { - if let Some(provider_dir) = candidate_skill_dir.parent() { - if SKILL_PROVIDER_DEFINITIONS.iter().any(|definition| { - provider_dir - .path_component() - .ends_with(&definition.skills_path.to_string_lossy()) - }) { - return Some(candidate_skill_dir.join("SKILL.md")); - } + if candidate_skill_dir + .parent() + .and_then(|provider_dir| provider_parent_directory_for_skills_root(&provider_dir)) + .is_some() + { + return Some(candidate_skill_dir.join("SKILL.md")); } current = candidate_skill_dir.parent(); } diff --git a/app/src/ai/skills/skill_utils_tests.rs b/app/src/ai/skills/skill_utils_tests.rs index 0e424e52d9..28cab63168 100644 --- a/app/src/ai/skills/skill_utils_tests.rs +++ b/app/src/ai/skills/skill_utils_tests.rs @@ -1,10 +1,39 @@ use std::path::PathBuf; use ai::skills::{ParsedSkill, SkillProvider, SkillScope}; -use warp_util::local_or_remote_path::LocalOrRemotePath; +use warp_util::{ + host_id::HostId, local_or_remote_path::LocalOrRemotePath, remote_path::RemotePath, + standardized_path::StandardizedPath, +}; use super::*; +fn remote_location(path: &str) -> LocalOrRemotePath { + LocalOrRemotePath::Remote(RemotePath::new( + HostId::new("remote-host".to_string()), + StandardizedPath::try_new(path).unwrap(), + )) +} + +#[test] +fn skill_path_from_unix_encoded_remote_location() { + let location = remote_location("/repo/.agents/skills/deploy/scripts/run.sh"); + + assert_eq!( + skill_path_from_location(&location), + Some(remote_location("/repo/.agents/skills/deploy/SKILL.md")) + ); +} + +#[test] +fn skill_path_from_windows_encoded_remote_location() { + let location = remote_location(r"C:\repo\.agents\skills\deploy\scripts\run.ps1"); + + assert_eq!( + skill_path_from_location(&location), + Some(remote_location(r"C:\repo\.agents\skills\deploy\SKILL.md")) + ); +} #[test] fn test_unique_skills_dedupes_identical_skills_same_dir() { let shared_skill_dir = PathBuf::from("/home/user"); diff --git a/crates/ai/src/skills/mod.rs b/crates/ai/src/skills/mod.rs index 44b742e412..5c8155abff 100644 --- a/crates/ai/src/skills/mod.rs +++ b/crates/ai/src/skills/mod.rs @@ -13,7 +13,7 @@ pub use parse_skill::{ }; pub use read_skills::read_skills; pub use skill_provider::{ - get_provider_for_path, home_skills_path, provider_rank, SkillProvider, SkillProviderDefinition, - SkillScope, SKILL_PROVIDER_DEFINITIONS, + get_provider_for_path, home_skills_path, provider_parent_directory_for_skills_root, + provider_rank, SkillProvider, SkillProviderDefinition, SkillScope, SKILL_PROVIDER_DEFINITIONS, }; pub use skill_reference::SkillReference; diff --git a/crates/ai/src/skills/skill_provider.rs b/crates/ai/src/skills/skill_provider.rs index 9fe1a0c495..a787a7d966 100644 --- a/crates/ai/src/skills/skill_provider.rs +++ b/crates/ai/src/skills/skill_provider.rs @@ -13,7 +13,6 @@ use warp_core::ui::color::CLAUDE_ORANGE; use warp_core::ui::icons::Icon; use warp_core::ui::theme::Fill; use warp_util::local_or_remote_path::LocalOrRemotePath; -use warp_util::standardized_path::StandardizedPath; /// Represents a skill provider/origin (Agents, Claude, Codex, or Warp). #[derive( @@ -175,7 +174,7 @@ pub fn home_skills_path(provider: SkillProvider) -> Option { pub fn get_provider_for_path(path: &LocalOrRemotePath) -> Option { path.to_local_path() .and_then(get_home_provider_for_local_path) - .or_else(|| get_provider_for_standardized_path(&path.path_component())) + .or_else(|| get_provider_for_structural_path(path)) } fn get_home_provider_for_local_path(path: &Path) -> Option { @@ -189,17 +188,47 @@ fn get_home_provider_for_local_path(path: &Path) -> Option { .map(|definition| definition.provider) } -fn get_provider_for_standardized_path(path: &StandardizedPath) -> Option { - SKILL_PROVIDER_DEFINITIONS - .iter() - .find(|definition| { - path.ancestors().any(|ancestor| { - ancestor - .to_local_path_lossy() - .ends_with(&definition.skills_path) - }) - }) - .map(|definition| definition.provider) +/// Returns the directory containing a provider's skills root when `skills_root` has a known +/// provider directory suffix, preserving the original local or remote location encoding. +/// +/// For example, `/repo/.agents/skills` resolves to `/repo`, regardless of whether the location +/// is encoded with Unix or Windows path separators. +pub fn provider_parent_directory_for_skills_root( + skills_root: &LocalOrRemotePath, +) -> Option { + match_provider_skills_root(skills_root).map(|(_, parent_directory)| parent_directory) +} + +fn get_provider_for_structural_path(path: &LocalOrRemotePath) -> Option { + let mut current = Some(path.clone()); + while let Some(candidate) = current { + if let Some((provider, _)) = match_provider_skills_root(&candidate) { + return Some(provider); + } + current = candidate.parent(); + } + None +} + +fn match_provider_skills_root( + skills_root: &LocalOrRemotePath, +) -> Option<(SkillProvider, LocalOrRemotePath)> { + for definition in SKILL_PROVIDER_DEFINITIONS.iter() { + let mut parent_directory = skills_root.clone(); + let mut matches_provider = true; + for component in definition.skills_path.components().rev() { + let expected_component = component.as_os_str().to_str()?; + if parent_directory.file_name() != Some(expected_component) { + matches_provider = false; + break; + } + parent_directory = parent_directory.parent()?; + } + if matches_provider { + return Some((definition.provider, parent_directory)); + } + } + None } /// Returns the skill scope (Home or Project) for a given path. @@ -219,15 +248,15 @@ pub fn get_scope_for_path(path: &Path) -> SkillScope { #[cfg(test)] mod tests { + use super::{ + get_provider_for_path, get_scope_for_path, home_skills_path, + provider_parent_directory_for_skills_root, SkillProvider, SkillScope, + }; use warp_util::host_id::HostId; use warp_util::local_or_remote_path::LocalOrRemotePath; use warp_util::remote_path::RemotePath; use warp_util::standardized_path::StandardizedPath; - use super::{ - get_provider_for_path, get_scope_for_path, home_skills_path, SkillProvider, SkillScope, - }; - #[test] fn warp_home_skills_path_uses_warp_home_path() { assert_eq!( @@ -284,4 +313,21 @@ mod tests { assert_eq!(get_provider_for_path(&path), Some(SkillProvider::Codex)); } + + #[test] + fn foreign_encoded_remote_skills_root_resolves_provider_parent_directory() { + let host_id = HostId::new("remote-host".to_string()); + let skills_root = LocalOrRemotePath::Remote(RemotePath::new( + host_id.clone(), + StandardizedPath::try_new(r"C:\repo\.agents\skills").unwrap(), + )); + + assert_eq!( + provider_parent_directory_for_skills_root(&skills_root), + Some(LocalOrRemotePath::Remote(RemotePath::new( + host_id, + StandardizedPath::try_new(r"C:\repo").unwrap(), + ))) + ); + } } From 5b667940fddcaf4b0f2d1c31c7470321aa9f9786 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Sun, 31 May 2026 00:20:34 -0700 Subject: [PATCH 4/4] Fix skill output location import formatting Co-Authored-By: Oz --- app/src/ai/blocklist/block/view_impl/output_tests.rs | 8 ++++---- app/src/ai/skills/skill_utils_tests.rs | 8 ++++---- crates/ai/src/skills/skill_provider.rs | 9 +++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/ai/blocklist/block/view_impl/output_tests.rs b/app/src/ai/blocklist/block/view_impl/output_tests.rs index 3b3312d108..1b48f2b881 100644 --- a/app/src/ai/blocklist/block/view_impl/output_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/output_tests.rs @@ -2,10 +2,10 @@ use ai::agent::action::UploadArtifactRequest; use ai::skills::{ParsedSkill, SkillProvider, SkillScope}; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::{DirectoryWatcher, RepoMetadataModel}; -use warp_util::{ - host_id::HostId, local_or_remote_path::LocalOrRemotePath, remote_path::RemotePath, - standardized_path::StandardizedPath, -}; +use warp_util::host_id::HostId; +use warp_util::local_or_remote_path::LocalOrRemotePath; +use warp_util::remote_path::RemotePath; +use warp_util::standardized_path::StandardizedPath; use warpui::App; use watcher::HomeDirectoryWatcher; diff --git a/app/src/ai/skills/skill_utils_tests.rs b/app/src/ai/skills/skill_utils_tests.rs index 28cab63168..5205af3c3c 100644 --- a/app/src/ai/skills/skill_utils_tests.rs +++ b/app/src/ai/skills/skill_utils_tests.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; use ai::skills::{ParsedSkill, SkillProvider, SkillScope}; -use warp_util::{ - host_id::HostId, local_or_remote_path::LocalOrRemotePath, remote_path::RemotePath, - standardized_path::StandardizedPath, -}; +use warp_util::host_id::HostId; +use warp_util::local_or_remote_path::LocalOrRemotePath; +use warp_util::remote_path::RemotePath; +use warp_util::standardized_path::StandardizedPath; use super::*; diff --git a/crates/ai/src/skills/skill_provider.rs b/crates/ai/src/skills/skill_provider.rs index a787a7d966..dbb31601a2 100644 --- a/crates/ai/src/skills/skill_provider.rs +++ b/crates/ai/src/skills/skill_provider.rs @@ -248,15 +248,16 @@ pub fn get_scope_for_path(path: &Path) -> SkillScope { #[cfg(test)] mod tests { - use super::{ - get_provider_for_path, get_scope_for_path, home_skills_path, - provider_parent_directory_for_skills_root, SkillProvider, SkillScope, - }; use warp_util::host_id::HostId; use warp_util::local_or_remote_path::LocalOrRemotePath; use warp_util::remote_path::RemotePath; use warp_util::standardized_path::StandardizedPath; + use super::{ + get_provider_for_path, get_scope_for_path, home_skills_path, + provider_parent_directory_for_skills_root, SkillProvider, SkillScope, + }; + #[test] fn warp_home_skills_path_uses_warp_home_path() { assert_eq!(