diff --git a/crates/cli/src/commands/apply.rs b/crates/cli/src/commands/apply.rs index d0629c60..728499dd 100644 --- a/crates/cli/src/commands/apply.rs +++ b/crates/cli/src/commands/apply.rs @@ -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), + } + } } diff --git a/crates/orchestrator/src/skills.rs b/crates/orchestrator/src/skills.rs index c82bd5f6..57cf731f 100644 --- a/crates/orchestrator/src/skills.rs +++ b/crates/orchestrator/src/skills.rs @@ -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 = 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 = + ["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 = ["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()); + } } diff --git a/crates/orchestrator/src/storage.rs b/crates/orchestrator/src/storage.rs index 04f7aa3d..9a13050a 100644 --- a/crates/orchestrator/src/storage.rs +++ b/crates/orchestrator/src/storage.rs @@ -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); + } }