Skip to content
Open
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
44 changes: 44 additions & 0 deletions crates/cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2063,4 +2063,48 @@ repo: myproject
other => panic!("Expected GitlabMergeRequests, got {:?}", other),
}
}

// ── AgentTemplate.skills field parsing ───────────────────────────────────

#[test]
fn test_parse_agent_skills_list() {
let yaml = "name: worker\nskills:\n - git-spice\n - agent-memory\n";
let tmpl: AgentTemplate = serde_yaml::from_str(yaml).unwrap();
match &tmpl.skills {
SkillsField::Named(names) => {
assert_eq!(names, &["git-spice", "agent-memory"]);
}
other => panic!("Expected Named, got {:?}", other),
}
}

#[test]
fn test_parse_agent_skills_all() {
let yaml = "name: worker\nskills: all\n";
let tmpl: AgentTemplate = serde_yaml::from_str(yaml).unwrap();
match &tmpl.skills {
SkillsField::All(s) => assert_eq!(s, "all"),
other => panic!("Expected All, got {:?}", other),
}
}

#[test]
fn test_parse_agent_skills_omitted_defaults_to_empty() {
let yaml = "name: worker\n";
let tmpl: AgentTemplate = serde_yaml::from_str(yaml).unwrap();
match &tmpl.skills {
SkillsField::Named(names) => assert!(names.is_empty()),
other => panic!("Expected Named([]), got {:?}", other),
}
}

#[test]
fn test_parse_agent_skills_empty_list() {
let yaml = "name: worker\nskills: []\n";
let tmpl: AgentTemplate = serde_yaml::from_str(yaml).unwrap();
match &tmpl.skills {
SkillsField::Named(names) => assert!(names.is_empty()),
other => panic!("Expected Named([]), got {:?}", other),
}
}
}
130 changes: 130 additions & 0 deletions crates/orchestrator/src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,4 +602,134 @@ mod tests {
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "valid");
}

// ── materialize_skills ───────────────────────────────────────────────────

fn make_discovered_skill(name: &str, description: &str) -> Skill {
Skill {
name: name.to_string(),
description: Some(description.to_string()),
content: format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}"),
source_path: PathBuf::from(format!(".agentd/skills/{name}.md")),
}
}

#[tokio::test]
async fn test_materialize_writes_skill_file() {
let tmp = TempDir::new().unwrap();
let skill = make_discovered_skill("git-spice", "Branch stacking.");
let discovered = vec![skill];

let result =
materialize_skills(tmp.path(), &["git-spice".to_string()], &discovered).await.unwrap();

assert_eq!(result.written, vec!["git-spice"]);
assert!(result.skipped.is_empty());
assert!(result.not_found.is_empty());

let dest = tmp.path().join(".claude/skills/git-spice/SKILL.md");
assert!(dest.exists(), "SKILL.md should exist at {}", dest.display());
let content = fs::read_to_string(&dest).unwrap();
assert!(content.contains("git-spice"));
}

#[tokio::test]
async fn test_materialize_creates_directory_structure() {
let tmp = TempDir::new().unwrap();
let skill = make_discovered_skill("agent-ops", "Agent operations.");
let discovered = vec![skill];

materialize_skills(tmp.path(), &["agent-ops".to_string()], &discovered).await.unwrap();

let skills_dir = tmp.path().join(".claude/skills/agent-ops");
assert!(skills_dir.is_dir(), ".claude/skills/agent-ops/ should be created");
}

#[tokio::test]
async fn test_materialize_does_not_overwrite_existing_file() {
let tmp = TempDir::new().unwrap();
// Pre-create the agent-local skill file.
let skill_dir = tmp.path().join(".claude/skills/git-spice");
fs::create_dir_all(&skill_dir).unwrap();
let existing = skill_dir.join("SKILL.md");
fs::write(&existing, "# My local version").unwrap();

let skill = make_discovered_skill("git-spice", "Different content.");
let discovered = vec![skill];

let result =
materialize_skills(tmp.path(), &["git-spice".to_string()], &discovered).await.unwrap();

assert!(result.written.is_empty());
assert_eq!(result.skipped, vec!["git-spice"]);

// Original content must be preserved.
let content = fs::read_to_string(&existing).unwrap();
assert_eq!(content, "# My local version");
}

#[tokio::test]
async fn test_materialize_reports_not_found() {
let tmp = TempDir::new().unwrap();
let discovered: Vec<Skill> = vec![]; // no skills discovered

let result = materialize_skills(tmp.path(), &["missing-skill".to_string()], &discovered)
.await
.unwrap();

assert!(result.written.is_empty());
assert!(result.skipped.is_empty());
assert_eq!(result.not_found, vec!["missing-skill"]);
}

#[tokio::test]
async fn test_materialize_empty_skill_list_is_noop() {
let tmp = TempDir::new().unwrap();
let skill = make_discovered_skill("git-spice", "Branch stacking.");
let discovered = vec![skill];

let result = materialize_skills(tmp.path(), &[], &discovered).await.unwrap();

assert!(result.written.is_empty());
assert!(result.skipped.is_empty());
assert!(result.not_found.is_empty());

// No .claude directory should have been created.
assert!(!tmp.path().join(".claude").exists());
}

#[tokio::test]
async fn test_materialize_multiple_skills() {
let tmp = TempDir::new().unwrap();
let discovered = vec![
make_discovered_skill("git-spice", "Branch stacking."),
make_discovered_skill("agent-memory", "Memory service."),
make_discovered_skill("service-ops", "Service operations."),
];

let names: Vec<String> =
["git-spice", "agent-memory", "missing"].iter().map(|s| s.to_string()).collect();

let result = materialize_skills(tmp.path(), &names, &discovered).await.unwrap();

let mut written = result.written.clone();
written.sort();
assert_eq!(written, vec!["agent-memory", "git-spice"]);
assert!(result.skipped.is_empty());
assert_eq!(result.not_found, vec!["missing"]);
}

#[tokio::test]
async fn test_materialize_written_count_matches_files() {
let tmp = TempDir::new().unwrap();
let discovered =
vec![make_discovered_skill("a", "Skill A."), make_discovered_skill("b", "Skill B.")];
let names: Vec<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();

let result = materialize_skills(tmp.path(), &names, &discovered).await.unwrap();

assert_eq!(result.written.len(), 2);
assert!(tmp.path().join(".claude/skills/a/SKILL.md").exists());
assert!(tmp.path().join(".claude/skills/b/SKILL.md").exists());
}
}
35 changes: 35 additions & 0 deletions crates/orchestrator/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2203,4 +2203,39 @@ mod tests {
assert_eq!(pruned, 0);
assert_eq!(storage.count_conversation_events(agent_id).await.unwrap(), 3);
}

// ── skills field tests ───────────────────────────────────────────────────

#[tokio::test]
async fn test_add_with_skills() {
let (storage, _tmp) = create_test_storage().await;
let mut agent = test_agent("skilled-agent");
agent.config.skills = vec!["git-spice".to_string(), "agent-memory".to_string()];
storage.add(&agent).await.unwrap();

let retrieved = storage.get(&agent.id).await.unwrap().unwrap();
assert_eq!(retrieved.config.skills, vec!["git-spice", "agent-memory"]);
}

#[tokio::test]
async fn test_skills_defaults_to_empty() {
let (storage, _tmp) = create_test_storage().await;
let agent = test_agent("no-skills-agent");
assert!(agent.config.skills.is_empty());
storage.add(&agent).await.unwrap();

let retrieved = storage.get(&agent.id).await.unwrap().unwrap();
assert!(retrieved.config.skills.is_empty());
}

#[tokio::test]
async fn test_skills_round_trips_through_storage() {
let (storage, _tmp) = create_test_storage().await;
let mut agent = test_agent("skill-roundtrip-agent");
agent.config.skills = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
storage.add(&agent).await.unwrap();

let retrieved = storage.get(&agent.id).await.unwrap().unwrap();
assert_eq!(retrieved.config.skills, agent.config.skills);
}
}