Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions crates/buzz-agent/src/hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,14 @@ fn scan_skill_dir(dir: &Path, seen: &mut HashSet<String>, skills: &mut Vec<Skill
};
let mut subdirs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
// Use std::fs::metadata (follows symlinks) rather than DirEntry::file_type
// (which returns FileType::Symlink for symlinks, causing is_dir() to return
// false even when the symlink target is a directory).
.filter(|e| {
std::fs::metadata(e.path())
.map(|m| m.is_dir())
.unwrap_or(false)
})
.map(|e| e.path())
.collect();
subdirs.sort();
Expand Down Expand Up @@ -164,8 +171,10 @@ fn collect_supporting_files_impl(current: &Path, out: &mut Vec<PathBuf>) {

for entry in items {
let path = entry.path();
let ft = match entry.file_type() {
Ok(ft) => ft,
// Use std::fs::metadata (follows symlinks) so symlinked subdirs and files
// inside a skill directory are handled correctly.
let ft = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if ft.is_dir() {
Expand Down
52 changes: 52 additions & 0 deletions crates/buzz-agent/tests/hints_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,58 @@ async fn global_skills_loaded_and_project_wins() {
h.shutdown().await;
}

/// Skill directories that are symlinks (e.g. managed by ai-rules) are discovered
/// correctly — `DirEntry::file_type()` returns `FileType::Symlink` for symlinks,
/// so the old `is_dir()` check silently dropped them. We now use
/// `std::fs::metadata()` which follows the symlink.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn symlinked_skill_dir_is_discovered() {
let real_skill_root = tempfile::TempDir::new().unwrap();
let real_skill_dir = real_skill_root.path().join("symlinked-skill");
std::fs::create_dir_all(&real_skill_dir).unwrap();
std::fs::write(
real_skill_dir.join("SKILL.md"),
"---\nname: symlinked-skill\ndescription: A symlinked skill\n---\nSYMLINK_SKILL_BODY_42\n",
)
.unwrap();

let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path();
let skills_dir = cwd.join(".agents/skills");
std::fs::create_dir_all(&skills_dir).unwrap();

// Create a symlink: .agents/skills/symlinked-skill -> real_skill_dir
std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap();

let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;

let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;

let captured = llm.captured.lock().await;
assert!(!captured.is_empty(), "no LLM request captured");
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
// The symlinked skill name must appear in the metadata listing.
assert!(
system.contains("symlinked-skill"),
"system prompt missing symlinked skill name: {system}"
);
// Body must NOT be inlined.
assert!(
!system.contains("SYMLINK_SKILL_BODY_42"),
"symlinked skill body must not be inlined in system prompt: {system}"
);
h.shutdown().await;
}

/// `load_skill` tool is advertised when skills exist, and returns the skill body.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn load_skill_tool_returns_body() {
Expand Down
Loading