//! Skill use cases (ARCHITECTURE §14.2; L12). //! //! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`], //! [`ListSkills`]. //! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate //! the project manifest entry's `skills` and announce //! [`DomainEvent::SkillAssigned`]. Both are idempotent. use std::sync::Arc; use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore, StoreError}; use domain::{ Agent, AgentId, AgentManifest, AssignedSkillSnapshot, ConversationParty, DomainEvent, MarkdownDoc, OrchestrationCapabilitySnapshot, Project, ProjectPath, Skill, SkillId, SkillKind, SkillRef, SkillScope, }; use crate::error::AppError; use crate::ProjectPluginResolver; // --------------------------------------------------------------------------- // CreateSkill // --------------------------------------------------------------------------- /// Input for [`CreateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSkillInput { /// Display name (also the `.md` stem on disk). pub name: String, /// Optional one-line affordance description (feature « skills à la MCP »). /// `None`/empty ⇒ the skill falls back to the first line of its body when /// surfaced (see [`domain::Skill::effective_description`]). pub description: Option, /// Capability nature to expose for this skill. Defaults to workflow when the /// caller does not specify it. pub kind: SkillKind, /// Initial Markdown body. pub content: String, /// Scope the skill is created in (selects its backing store). pub scope: SkillScope, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Output of [`CreateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSkillOutput { /// The created skill. pub skill: Skill, } /// Creates a skill in the store of its [`SkillScope`]. pub struct CreateSkill { skills: Arc, ids: Arc, } impl CreateSkill { /// Builds the use case from its ports. #[must_use] pub fn new(skills: Arc, ids: Arc) -> Self { Self { skills, ids } } /// Executes creation. /// /// # Errors /// - [`AppError::Invalid`] if `name`/`content` is empty, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: CreateSkillInput) -> Result { let id = SkillId::from_uuid(self.ids.new_uuid()); let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope) .map_err(|e| AppError::Invalid(e.to_string()))? .with_description(input.description) .with_kind(input.kind); self.skills.save(&skill, &input.project_root).await?; Ok(CreateSkillOutput { skill }) } } // --------------------------------------------------------------------------- // UpdateSkill // --------------------------------------------------------------------------- /// Input for [`UpdateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateSkillInput { /// Scope the skill lives in. pub scope: SkillScope, /// Skill to update. pub skill_id: SkillId, /// New Markdown body. pub content: String, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Output of [`UpdateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateSkillOutput { /// The updated skill. pub skill: Skill, } /// Replaces a skill's content (re-validating the non-empty invariant). pub struct UpdateSkill { skills: Arc, } impl UpdateSkill { /// Builds the use case. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Executes the update. /// /// # Errors /// - [`AppError::NotFound`] if the skill is unknown in that scope, /// - [`AppError::Invalid`] if the new content is empty, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: UpdateSkillInput) -> Result { let current = self .skills .get(input.scope, &input.project_root, input.skill_id) .await?; let updated = current .with_content(MarkdownDoc::new(input.content)) .map_err(|e| AppError::Invalid(e.to_string()))?; self.skills.save(&updated, &input.project_root).await?; Ok(UpdateSkillOutput { skill: updated }) } } // --------------------------------------------------------------------------- // ListSkills / DeleteSkill // --------------------------------------------------------------------------- /// Input for [`ListSkills::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSkillsInput { /// Scope to list. pub scope: SkillScope, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Output of [`ListSkills::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSkillsOutput { /// All skills in the requested scope. pub skills: Vec, } /// Lists the skills in one scope. pub struct ListSkills { skills: Arc, } impl ListSkills { /// Builds the use case. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Lists skills in `input.scope`. /// /// # Errors /// [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: ListSkillsInput) -> Result { Ok(ListSkillsOutput { skills: self.skills.list(input.scope, &input.project_root).await?, }) } } /// Input for [`DeleteSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteSkillInput { /// Scope the skill lives in. pub scope: SkillScope, /// Skill to delete. pub skill_id: SkillId, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Deletes a skill from its scope's store. /// /// Agents that referenced it keep their [`SkillRef`]; the injection step simply /// finds nothing to resolve for the now-absent skill and skips it. pub struct DeleteSkill { skills: Arc, } impl DeleteSkill { /// Builds the use case. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Deletes the skill. /// /// # Errors /// - [`AppError::NotFound`] if the skill is unknown in that scope, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> { self.skills .delete(input.scope, &input.project_root, input.skill_id) .await?; Ok(()) } } // --------------------------------------------------------------------------- // ResolveAgentCapabilities / ReadSkill // --------------------------------------------------------------------------- /// One discoverable capability carried by an agent. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentCapability { /// Capability display name. pub name: String, /// One-line affordance description. pub description: String, /// Capability nature. pub kind: SkillKind, } impl From<&AssignedSkillSnapshot> for AgentCapability { fn from(skill: &AssignedSkillSnapshot) -> Self { Self { name: skill.name.clone(), description: skill.description.clone(), kind: skill.kind, } } } /// Input for [`ResolveAgentCapabilities::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolveAgentCapabilitiesInput { /// Agent whose assigned skills must be resolved. pub agent: Agent, /// Active project root used to resolve project-scoped skills. pub project_root: ProjectPath, } /// Resolves assigned skills into discoverable agent capabilities. pub struct ResolveAgentCapabilities { resolver: Arc, } impl ResolveAgentCapabilities { /// Builds the use case from a skill store. #[must_use] pub fn new(skills: Arc) -> Self { Self { resolver: Arc::new(AssignedSkillResolver::new(skills)), } } /// Builds the use case from a shared resolver. #[must_use] pub fn with_resolver(resolver: Arc) -> Self { Self { resolver } } /// Resolves the compact capability list, skipping dangling skill refs. /// /// # Errors /// [`AppError::Store`] on store failures other than a missing skill. pub async fn execute( &self, input: ResolveAgentCapabilitiesInput, ) -> Result, AppError> { let snapshot = self .resolver .resolve_for_agent(&input.agent, &input.project_root) .await?; Ok(snapshot .assigned_skills .iter() .map(AgentCapability::from) .collect()) } } /// Resolves assigned skills into the runtime capability snapshot used by both /// context rendering and `idea_skill_read` authorization. pub struct AssignedSkillResolver { skills: Arc, plugin_skills: Option>, } impl AssignedSkillResolver { /// Builds the resolver from the existing skill store port. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills, plugin_skills: None, } } /// Adds the provider-agnostic project-plugin resolver. #[must_use] pub fn with_plugin_skills(mut self, plugin_skills: Arc) -> Self { self.plugin_skills = Some(plugin_skills); self } /// Resolves the assigned skills for `agent` in manifest order. /// /// Dangling assignments are skipped: a deleted skill must not block a launch, /// and a skipped skill is therefore not readable via `idea_skill_read`. /// /// # Errors /// [`AppError::Store`] on store failures other than a missing skill. pub async fn resolve_for_agent( &self, agent: &Agent, root: &ProjectPath, ) -> Result { let resolved = self.resolve_for_agent_with_content(agent, root).await?; Ok(OrchestrationCapabilitySnapshot::new( resolved.into_iter().map(|(snapshot, _)| snapshot).collect(), )) } /// Resolves assigned skills and keeps their Markdown bodies for non-MCP /// fallback context injection. /// /// # Errors /// [`AppError::Store`] on store failures other than a missing skill. pub async fn resolve_for_agent_with_content( &self, agent: &Agent, root: &ProjectPath, ) -> Result, AppError> { let mut assigned = Vec::with_capacity(agent.skills.len()); for (index, skill_ref) in agent.skills.iter().enumerate() { match self .skills .get(skill_ref.scope, root, skill_ref.skill_id) .await { Ok(skill) => { let description = skill.effective_description(); let snapshot = AssignedSkillSnapshot { skill_id: skill.id, scope: skill.scope, plugin: None, name: skill.name, description, kind: skill.kind, assignment_index: index as u32, }; assigned.push((snapshot, skill.content_md)); } Err(StoreError::NotFound) => {} Err(e) => return Err(e.into()), } } if let Some(resolver) = &self.plugin_skills { let settings = resolver.settings(root).await?; if let Some(assignments) = settings.assignments_for(agent.id) { for (offset, reference) in assignments.skills.iter().enumerate() { let Some(manifest) = resolver .enabled_manifest(root, &reference.plugin_id) .await? else { continue; }; let Some(skill) = manifest .contributes .skills .iter() .find(|s| s.id == reference.skill_id) else { continue; }; let content = match resolver .read_skill_body(&reference.plugin_id, &skill.path) .await { Ok(content) => content, Err(AppError::NotFound(_)) => continue, Err(error) => return Err(error), }; assigned.push(( AssignedSkillSnapshot { // Native fields remain populated for backwards wire compatibility; // plugin identity is authoritative when `plugin` is present. skill_id: SkillId::from_uuid(uuid::Uuid::nil()), scope: SkillScope::Project, plugin: Some(domain::orchestrator::AssignedPluginSkillSource { plugin_id: reference.plugin_id.clone(), skill_id: reference.skill_id.clone(), }), name: skill.name.clone(), description: skill.description.clone(), kind: skill.kind, assignment_index: (agent.skills.len() + offset) as u32, }, content, )); } } } Ok(assigned) } /// Reads the Markdown body for one skill by name, but only inside the supplied /// assigned-skill snapshot. /// /// # Errors /// - [`AppError::NotFound`] if `name` is not assigned in `snapshot`, /// - [`AppError::Invalid`] if the assigned snapshot has ambiguous names, /// - [`AppError::Store`] on a store failure. pub async fn read_assigned_by_name( &self, snapshot: &OrchestrationCapabilitySnapshot, root: &ProjectPath, name: &str, ) -> Result { let mut matches = snapshot .assigned_skills .iter() .filter(|s| s.name.eq_ignore_ascii_case(name)); let selected = match (matches.next(), matches.next()) { (None, _) => return Err(AppError::NotFound(format!("assigned skill `{name}`"))), (Some(skill), None) => skill, (Some(_), Some(_)) => { return Err(AppError::Invalid(format!( "assigned skill name `{name}` is ambiguous for this agent" ))) } }; if let Some(plugin) = &selected.plugin { let resolver = self .plugin_skills .as_ref() .ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?; let manifest = resolver .enabled_manifest(root, &plugin.plugin_id) .await? .ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?; let contribution = manifest .contributes .skills .iter() .find(|s| s.id == plugin.skill_id) .ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?; return resolver .read_skill_body(&plugin.plugin_id, &contribution.path) .await; } let skill = self .skills .get(selected.scope, root, selected.skill_id) .await?; Ok(skill.content_md) } } /// Input for [`ReadSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadSkillInput { /// Skill display name to resolve (case-insensitive). pub name: String, /// Active project. pub project: Project, /// Current requester identity from the orchestration handshake. pub requester: ConversationParty, } /// Reads an assigned skill's Markdown body **by name** — the application side of /// the MCP `idea_skill_read` tool. /// /// The same [`AssignedSkillResolver`] that builds the runtime capability /// snapshot authorizes the read. A model can read only skills assigned to the /// requesting agent in the current manifest. pub struct ReadSkill { contexts: Arc, resolver: Arc, } impl ReadSkill { /// Builds the use case from existing ports. #[must_use] pub fn new(contexts: Arc, skills: Arc) -> Self { Self { contexts, resolver: Arc::new(AssignedSkillResolver::new(skills)), } } /// Builds the use case from a shared resolver. #[must_use] pub fn with_resolver( contexts: Arc, resolver: Arc, ) -> Self { Self { contexts, resolver } } /// Resolves the requester's assigned snapshot and returns the skill body. /// /// # Errors /// - [`AppError::Invalid`] if requester is not an agent or the assigned name is ambiguous, /// - [`AppError::NotFound`] if requester/skill is absent, /// - [`AppError::Store`] on store failures. pub async fn execute(&self, input: ReadSkillInput) -> Result { let requester = input.requester.as_agent().ok_or_else(|| { AppError::Invalid("idea_skill_read requires an agent requester".to_owned()) })?; let manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter() .find(|e| e.agent_id == requester) .ok_or_else(|| AppError::NotFound(format!("agent {requester}")))?; let agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; let snapshot = self .resolver .resolve_for_agent(&agent, &input.project.root) .await?; self.resolver .read_assigned_by_name(&snapshot, &input.project.root, &input.name) .await } } // --------------------------------------------------------------------------- // AssignSkillToAgent / UnassignSkillFromAgent // --------------------------------------------------------------------------- /// Input for [`AssignSkillToAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssignSkillToAgentInput { /// The owning project. pub project: Project, /// The agent receiving the skill. pub agent_id: AgentId, /// The skill to assign. pub skill: SkillRef, } /// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry. /// Idempotent: re-assigning the same skill is a no-op (no duplicate). pub struct AssignSkillToAgent { contexts: Arc, events: Arc, } impl AssignSkillToAgent { /// Builds the use case from its ports. #[must_use] pub fn new(contexts: Arc, events: Arc) -> Self { Self { contexts, events } } /// Executes the assignment. /// /// # Errors /// - [`AppError::NotFound`] if the agent is unknown to the project, /// - [`AppError::Invalid`] if the resulting manifest is invalid, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> { let mut manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter_mut() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; // Mutate through the domain entity so the dedup invariant is enforced in // one place, then fold the result back into the manifest entry. let mut agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; let changed = agent.assign_skill(input.skill); *entry = domain::ManifestEntry::from_agent(&agent); if changed { self.persist_and_announce( &input.project, manifest, input.agent_id, input.skill.skill_id, true, ) .await?; } Ok(()) } /// Saves the manifest and announces the assignment change. async fn persist_and_announce( &self, project: &Project, manifest: AgentManifest, agent_id: AgentId, skill_id: SkillId, assigned: bool, ) -> Result<(), AppError> { let manifest = AgentManifest::with_orchestrator( manifest.version, manifest.entries, manifest.orchestrator, ) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts.save_manifest(project, &manifest).await?; self.events.publish(DomainEvent::SkillAssigned { agent_id, skill_id, assigned, }); Ok(()) } } /// Input for [`UnassignSkillFromAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnassignSkillFromAgentInput { /// The owning project. pub project: Project, /// The agent losing the skill. pub agent_id: AgentId, /// The skill to unassign. pub skill_id: SkillId, } /// Removes a skill assignment from an agent. Idempotent: unassigning a skill the /// agent does not carry is a no-op. pub struct UnassignSkillFromAgent { contexts: Arc, events: Arc, } impl UnassignSkillFromAgent { /// Builds the use case from its ports. #[must_use] pub fn new(contexts: Arc, events: Arc) -> Self { Self { contexts, events } } /// Executes the un-assignment. /// /// # Errors /// - [`AppError::NotFound`] if the agent is unknown to the project, /// - [`AppError::Invalid`] if the resulting manifest is invalid, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> { let mut manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter_mut() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let mut agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; let changed = agent.unassign_skill(input.skill_id); *entry = domain::ManifestEntry::from_agent(&agent); if changed { let manifest = AgentManifest::with_orchestrator( manifest.version, manifest.entries, manifest.orchestrator, ) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; self.events.publish(DomainEvent::SkillAssigned { agent_id: input.agent_id, skill_id: input.skill_id, assigned: false, }); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; use async_trait::async_trait; use domain::ports::{AgentContextStore, SkillStore, StoreError}; use domain::remote::RemoteRef; /// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the /// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only /// switches scope). Enough to exercise project-first resolution, shadowing, /// ambiguity and absence without touching disk. #[derive(Default)] struct FakeSkillStore { global: Mutex>, project: Mutex>, } impl FakeSkillStore { fn bucket(&self, scope: SkillScope) -> &Mutex> { match scope { SkillScope::Global => &self.global, SkillScope::Project => &self.project, } } fn push(&self, skill: Skill) { self.bucket(skill.scope).lock().unwrap().push(skill); } } #[async_trait] impl SkillStore for FakeSkillStore { async fn list( &self, scope: SkillScope, _root: &ProjectPath, ) -> Result, StoreError> { Ok(self.bucket(scope).lock().unwrap().clone()) } async fn get( &self, scope: SkillScope, _root: &ProjectPath, id: SkillId, ) -> Result { self.bucket(scope) .lock() .unwrap() .iter() .find(|s| s.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { self.push(skill.clone()); Ok(()) } async fn delete( &self, _scope: SkillScope, _root: &ProjectPath, _id: SkillId, ) -> Result<(), StoreError> { Ok(()) } } struct FakeAgentContextStore { manifest: Mutex, } impl FakeAgentContextStore { fn new(manifest: AgentManifest) -> Self { Self { manifest: Mutex::new(manifest), } } } #[async_trait] impl AgentContextStore for FakeAgentContextStore { async fn read_context( &self, _project: &Project, _agent: &AgentId, ) -> Result { Ok(MarkdownDoc::new("# Persona")) } async fn write_context( &self, _project: &Project, _agent: &AgentId, _md: &MarkdownDoc, ) -> Result<(), StoreError> { Ok(()) } async fn load_manifest(&self, _project: &Project) -> Result { Ok(self.manifest.lock().unwrap().clone()) } async fn save_manifest( &self, _project: &Project, manifest: &AgentManifest, ) -> Result<(), StoreError> { *self.manifest.lock().unwrap() = manifest.clone(); Ok(()) } } fn root() -> ProjectPath { ProjectPath::new("/proj").unwrap() } fn project() -> Project { Project::new( domain::ProjectId::from_uuid(uuid::Uuid::from_u128(100)), "Project", root(), RemoteRef::Local, 0, ) .unwrap() } fn agent_id(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn profile_id(n: u128) -> domain::ProfileId { domain::ProfileId::from_uuid(uuid::Uuid::from_u128(n)) } fn manifest_for(agent_id: AgentId, skills: Vec) -> AgentManifest { let agent = Agent::new( agent_id, "Dev", "agents/dev.md", profile_id(900), domain::AgentOrigin::Scratch, false, ) .unwrap() .with_skills(skills); AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap() } fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill { Skill::new( SkillId::from_uuid(uuid::Uuid::from_u128(id)), name, MarkdownDoc::new(body), scope, ) .unwrap() } #[tokio::test] async fn resolve_agent_capabilities_returns_compact_kind_aware_affordances() { let store = Arc::new(FakeSkillStore::default()); store.push( skill(1, "deploy", "# Deploy\n\nbody", SkillScope::Project) .with_description(Some("Deploys safely".to_owned())), ); store.push( skill(2, "runbook", "# Runbook", SkillScope::Global).with_kind(SkillKind::Reference), ); let requester = agent_id(10); let agent = manifest_for( requester, vec![ SkillRef::new( SkillId::from_uuid(uuid::Uuid::from_u128(1)), SkillScope::Project, ), SkillRef::new( SkillId::from_uuid(uuid::Uuid::from_u128(99)), SkillScope::Project, ), SkillRef::new( SkillId::from_uuid(uuid::Uuid::from_u128(2)), SkillScope::Global, ), ], ) .entries[0] .to_agent() .unwrap(); let uc = ResolveAgentCapabilities::new(store); let capabilities = uc .execute(ResolveAgentCapabilitiesInput { agent, project_root: root(), }) .await .unwrap(); assert_eq!(capabilities.len(), 2); assert_eq!(capabilities[0].name, "deploy"); assert_eq!(capabilities[0].description, "Deploys safely"); assert_eq!(capabilities[0].kind, SkillKind::Workflow); assert_eq!(capabilities[1].name, "runbook"); assert_eq!(capabilities[1].description, "Runbook"); assert_eq!(capabilities[1].kind, SkillKind::Reference); } fn read_skill_uc( store: Arc, manifest: AgentManifest, ) -> (ReadSkill, Project, AgentId) { let requester = manifest.entries[0].agent_id; let contexts = Arc::new(FakeAgentContextStore::new(manifest)); (ReadSkill::new(contexts, store), project(), requester) } #[tokio::test] async fn read_skill_returns_assigned_project_skill() { let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project)); let requester = agent_id(10); let manifest = manifest_for( requester, vec![SkillRef { skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)), scope: SkillScope::Project, }], ); let (uc, project, requester) = read_skill_uc(store, manifest); let body = uc .execute(ReadSkillInput { name: "deploy".to_owned(), project, requester: ConversationParty::agent(requester), }) .await .unwrap(); assert_eq!(body.as_str(), "PROJECT_BODY"); } #[tokio::test] async fn read_skill_returns_assigned_global_skill() { let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global)); let requester = agent_id(10); let manifest = manifest_for( requester, vec![SkillRef { skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)), scope: SkillScope::Global, }], ); let (uc, project, requester) = read_skill_uc(store, manifest); let body = uc .execute(ReadSkillInput { name: "deploy".to_owned(), project, requester: ConversationParty::agent(requester), }) .await .unwrap(); assert_eq!(body.as_str(), "GLOBAL_BODY"); } #[tokio::test] async fn read_skill_refuses_unassigned_name_even_if_skill_exists() { let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project)); let requester = agent_id(10); let manifest = manifest_for(requester, Vec::new()); let (uc, project, requester) = read_skill_uc(store, manifest); let err = uc .execute(ReadSkillInput { name: "deploy".to_owned(), project, requester: ConversationParty::agent(requester), }) .await .unwrap_err(); assert!(matches!(err, AppError::NotFound(_)), "got {err:?}"); } #[tokio::test] async fn read_skill_is_case_insensitive_within_assigned_snapshot() { let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "Deploy", "BODY", SkillScope::Project)); let requester = agent_id(10); let manifest = manifest_for( requester, vec![SkillRef { skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)), scope: SkillScope::Project, }], ); let (uc, project, requester) = read_skill_uc(store, manifest); let body = uc .execute(ReadSkillInput { name: "deploy".to_owned(), project, requester: ConversationParty::agent(requester), }) .await .unwrap(); assert_eq!(body.as_str(), "BODY"); } #[tokio::test] async fn read_skill_requires_agent_requester() { let store = Arc::new(FakeSkillStore::default()); let requester = agent_id(10); let manifest = manifest_for(requester, Vec::new()); let (uc, project, _) = read_skill_uc(store, manifest); let err = uc .execute(ReadSkillInput { name: "ghost".to_owned(), project, requester: ConversationParty::User, }) .await .unwrap_err(); assert!(matches!(err, AppError::Invalid(_)), "got {err:?}"); } #[tokio::test] async fn read_skill_ambiguous_assigned_name_is_invalid() { let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "ONE", SkillScope::Project)); store.push(skill(2, "Deploy", "TWO", SkillScope::Global)); let requester = agent_id(10); let manifest = manifest_for( requester, vec![ SkillRef { skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)), scope: SkillScope::Project, }, SkillRef { skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(2)), scope: SkillScope::Global, }, ], ); let (uc, project, requester) = read_skill_uc(store, manifest); let err = uc .execute(ReadSkillInput { name: "deploy".to_owned(), project, requester: ConversationParty::agent(requester), }) .await .unwrap_err(); assert!(matches!(err, AppError::Invalid(_)), "got {err:?}"); } }