Files
IdeA/crates/application/src/skill/usecases.rs

1064 lines
35 KiB
Rust

//! 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<String>,
/// 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<dyn SkillStore>,
ids: Arc<dyn IdGenerator>,
}
impl CreateSkill {
/// Builds the use case from its ports.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> 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<CreateSkillOutput, AppError> {
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<dyn SkillStore>,
}
impl UpdateSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> 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<UpdateSkillOutput, AppError> {
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<Skill>,
}
/// Lists the skills in one scope.
pub struct ListSkills {
skills: Arc<dyn SkillStore>,
}
impl ListSkills {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Lists skills in `input.scope`.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
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<dyn SkillStore>,
}
impl DeleteSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> 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<AssignedSkillResolver>,
}
impl ResolveAgentCapabilities {
/// Builds the use case from a skill store.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self {
resolver: Arc::new(AssignedSkillResolver::new(skills)),
}
}
/// Builds the use case from a shared resolver.
#[must_use]
pub fn with_resolver(resolver: Arc<AssignedSkillResolver>) -> 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<Vec<AgentCapability>, 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<dyn SkillStore>,
plugin_skills: Option<Arc<ProjectPluginResolver>>,
}
impl AssignedSkillResolver {
/// Builds the resolver from the existing skill store port.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> 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<ProjectPluginResolver>) -> 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<OrchestrationCapabilitySnapshot, AppError> {
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<Vec<(AssignedSkillSnapshot, MarkdownDoc)>, 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<MarkdownDoc, AppError> {
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<dyn AgentContextStore>,
resolver: Arc<AssignedSkillResolver>,
}
impl ReadSkill {
/// Builds the use case from existing ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, skills: Arc<dyn SkillStore>) -> 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<dyn AgentContextStore>,
resolver: Arc<AssignedSkillResolver>,
) -> 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<MarkdownDoc, AppError> {
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<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl AssignSkillToAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> 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<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl UnassignSkillFromAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> 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<Vec<Skill>>,
project: Mutex<Vec<Skill>>,
}
impl FakeSkillStore {
fn bucket(&self, scope: SkillScope) -> &Mutex<Vec<Skill>> {
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<Vec<Skill>, StoreError> {
Ok(self.bucket(scope).lock().unwrap().clone())
}
async fn get(
&self,
scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
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<AgentManifest>,
}
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<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
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<SkillRef>) -> 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<FakeSkillStore>,
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:?}");
}
}