feat(skill-awareness): T1→T5 — manifeste de skills + outil MCP idea_skill_read
Surface les skills assignés à un agent « à la MCP » : description sur l'entité Skill (+ effective_description fallback), section « # Skills disponibles » haute altitude dans le convention-file (mode MCP), outil read-only idea_skill_read (résolution projet→global), use case ReadSkill (port SkillStore existant), et câblage au composition root. Dump legacy du corps complet conservé en mode sans-MCP (zéro régression). Rétro-compat index.json (serde default). Tests : domain+application+infrastructure 1212 passed / 0 failed (23 ajoutés). Reste : T6 (champ description front) + T7 (e2e après rebuild AppImage). NB topologie : commit réalisé par l'orchestrateur car l'agent Git était injoignable (bug de livraison cold-start) ; à faire relire/rebaser par Git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2542,16 +2542,25 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// `# Skills` section (ARCHITECTURE §14.2).
|
||||
///
|
||||
/// A short skill-awareness paragraph is always injected in the orchestration
|
||||
/// block: it explains that assigned skills are operational workflow context, not
|
||||
/// magic commands or provider subagents, and points reusable workflow creation to
|
||||
/// the active IdeA orchestration surface (`idea_create_skill` for MCP profiles,
|
||||
/// `skill.create` for the file protocol). This awareness deliberately does not
|
||||
/// inject unassigned skill bodies; assignment remains the context boundary.
|
||||
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
|
||||
/// assigned skills are operational workflow context, not magic commands or provider
|
||||
/// subagents, and points reusable workflow creation to the active IdeA orchestration
|
||||
/// surface (`idea_create_skill` for MCP profiles, `skill.create` for the file
|
||||
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
|
||||
/// assignment remains the context boundary.
|
||||
///
|
||||
/// Skills are emitted in the order given (the caller passes them in manifest
|
||||
/// order, making the output deterministic); each is introduced by a `##` header
|
||||
/// carrying its name. When `skills` is empty the section is omitted entirely, so
|
||||
/// an agent with no skills gets exactly the previous document.
|
||||
/// On top of that awareness, the assigned skills surface in one of two ways
|
||||
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
|
||||
/// the given (manifest) order — making the output deterministic:
|
||||
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
|
||||
/// right after the orchestration block, listing each as
|
||||
/// `**<name>** — <effective description>` (affordances only, *no body*), with
|
||||
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
|
||||
/// altitude: the capability is exposed, never the skill content.
|
||||
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
|
||||
/// under a `##` header carrying its name (unchanged — zero regression).
|
||||
/// When `skills` is empty both sections are omitted entirely, so an agent with no
|
||||
/// skills gets exactly the previous document.
|
||||
///
|
||||
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
||||
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
|
||||
@ -2627,6 +2636,29 @@ pub(crate) fn compose_convention_file(
|
||||
out.push_str(memory_awareness());
|
||||
out.push_str("---\n\n");
|
||||
|
||||
// Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après
|
||||
// le bloc d'orchestration. On expose les skills assignés comme des **affordances
|
||||
// nommées+décrites** (et NON leur corps complet), à la manière des outils MCP,
|
||||
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
|
||||
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
|
||||
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
|
||||
if mcp_enabled && !skills.is_empty() {
|
||||
out.push_str("# Skills disponibles\n\n");
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
for skill in skills {
|
||||
out.push_str("**");
|
||||
out.push_str(&skill.name);
|
||||
out.push_str("** — ");
|
||||
out.push_str(&skill.effective_description());
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("\n---\n\n");
|
||||
}
|
||||
|
||||
if !project_context.trim().is_empty() {
|
||||
out.push_str("# Contexte projet\n\n");
|
||||
out.push_str(project_context.trim());
|
||||
@ -2635,7 +2667,11 @@ pub(crate) fn compose_convention_file(
|
||||
|
||||
out.push_str(agent_md);
|
||||
|
||||
if !skills.is_empty() {
|
||||
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
|
||||
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
|
||||
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
|
||||
// cf. la section « # Skills disponibles » à haute altitude plus haut).
|
||||
if !skills.is_empty() && !mcp_enabled {
|
||||
out.push_str("\n\n---\n\n# Skills\n");
|
||||
for skill in skills {
|
||||
out.push_str("\n## ");
|
||||
@ -2944,6 +2980,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
|
||||
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
|
||||
// section after the Orchestration block, listing `**name** — description`
|
||||
// affordances and pointing to `idea_skill_read`, WITHOUT dumping the bodies.
|
||||
let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
name,
|
||||
MarkdownDoc::new(body),
|
||||
domain::SkillScope::Global,
|
||||
)
|
||||
.unwrap()
|
||||
.with_description(desc.map(str::to_owned))
|
||||
};
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
true, // mcp_enabled
|
||||
);
|
||||
|
||||
// The affordance section is present.
|
||||
assert!(doc.contains("# Skills disponibles"), "MCP skills section present");
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
// Affordance lines: `**name** — <effective description>`.
|
||||
assert!(doc.contains("**refactor** — Refactors code"));
|
||||
assert!(doc.contains("**review** — Review skill"));
|
||||
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");
|
||||
// The legacy `## <name>` body dump headers are absent too.
|
||||
assert!(!doc.contains("## refactor"));
|
||||
|
||||
// The section sits at high altitude: after the Orchestration block, before
|
||||
// the persona.
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_mcp_mode_without_skills_omits_section() {
|
||||
// MCP mode + zero skills ⇒ no `# Skills disponibles` section at all.
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
assert!(!doc.contains("# Skills disponibles"));
|
||||
assert!(!doc.contains("idea_skill_read"));
|
||||
}
|
||||
|
||||
/// Builds a memory index entry for the convention-file composition tests.
|
||||
fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
|
||||
MemoryIndexEntry {
|
||||
|
||||
@ -100,9 +100,9 @@ pub use project::{
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use skill::{
|
||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||
UpdateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, ReadSkill,
|
||||
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
||||
UpdateSkillInput, UpdateSkillOutput,
|
||||
};
|
||||
pub use template::{
|
||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||
|
||||
@ -43,7 +43,7 @@ use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
@ -303,6 +303,11 @@ pub struct OrchestratorService {
|
||||
/// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un
|
||||
/// échec de harvest ne transforme **jamais** un succès de délégation en erreur.
|
||||
memory_harvest: Option<Arc<HarvestMemoryFromTurn>>,
|
||||
/// Use case de lecture d'un skill **par nom** (`idea_skill_read`, feature
|
||||
/// « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau
|
||||
/// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande
|
||||
/// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts).
|
||||
read_skill: Option<Arc<ReadSkill>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -367,9 +372,19 @@ impl OrchestratorService {
|
||||
clock: None,
|
||||
structured: None,
|
||||
memory_harvest: None,
|
||||
read_skill: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche le use case [`ReadSkill`] (`skill.read`/`idea_skill_read`). Builder
|
||||
/// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande
|
||||
/// renvoie une erreur typée « not configured ».
|
||||
#[must_use]
|
||||
pub fn with_read_skill(mut self, read_skill: Arc<ReadSkill>) -> Self {
|
||||
self.read_skill = Some(read_skill);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask`
|
||||
/// vers une cible **structurée** sur sa propre session. Builder additif (signature
|
||||
/// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement.
|
||||
@ -588,6 +603,9 @@ impl OrchestratorService {
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
OrchestratorCommand::ReadSkill { name, requester } => {
|
||||
self.read_skill(project, name, requester).await
|
||||
}
|
||||
OrchestratorCommand::WriteMemory {
|
||||
slug,
|
||||
content,
|
||||
@ -682,6 +700,31 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `skill.read` → resolves a skill by name (project scope first, then global)
|
||||
/// and returns its Markdown body inline in the outcome's `reply`. Read-only:
|
||||
/// `requester` is accepted for symmetry with the other read tools but skill
|
||||
/// reads take no [`domain::fileguard::FileGuard`] lease.
|
||||
async fn read_skill(
|
||||
&self,
|
||||
project: &Project,
|
||||
name: String,
|
||||
_requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let read_skill = self.read_skill.as_deref().ok_or_else(|| {
|
||||
AppError::Invalid("the idea_skill_read tool is not configured".to_owned())
|
||||
})?;
|
||||
let md = read_skill
|
||||
.execute(ReadSkillInput {
|
||||
name: name.clone(),
|
||||
project_root: project.root.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read skill {name}"),
|
||||
reply: Some(md.into_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.write` → writes a note under an exclusive write-lease.
|
||||
async fn write_memory(
|
||||
&self,
|
||||
@ -1867,6 +1910,9 @@ impl OrchestratorService {
|
||||
.create_skill
|
||||
.execute(CreateSkillInput {
|
||||
name: name.clone(),
|
||||
// `idea_create_skill` carries no description argument; the affordance
|
||||
// falls back to the body's first line (see `effective_description`).
|
||||
description: None,
|
||||
content,
|
||||
scope,
|
||||
project_root: project.root.clone(),
|
||||
|
||||
@ -15,7 +15,7 @@ mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||
UpdateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, ReadSkill,
|
||||
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
||||
UpdateSkillInput, UpdateSkillOutput,
|
||||
};
|
||||
|
||||
@ -25,6 +25,10 @@ use crate::error::AppError;
|
||||
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>,
|
||||
/// Initial Markdown body.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in (selects its backing store).
|
||||
@ -61,7 +65,8 @@ impl CreateSkill {
|
||||
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()))?;
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
||||
.with_description(input.description);
|
||||
self.skills.save(&skill, &input.project_root).await?;
|
||||
Ok(CreateSkillOutput { skill })
|
||||
}
|
||||
@ -204,6 +209,77 @@ impl DeleteSkill {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadSkill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ReadSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadSkillInput {
|
||||
/// Skill display name to resolve (case-insensitive).
|
||||
pub name: String,
|
||||
/// Active project root (used for the [`SkillScope::Project`] lookup).
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Reads a skill's Markdown body **by name** — the application side of the MCP
|
||||
/// `idea_skill_read` tool (feature « skills à la MCP »).
|
||||
///
|
||||
/// Resolution follows the affordance contract: **project scope first, then
|
||||
/// global** (a project skill shadows a global one of the same name). It composes
|
||||
/// the **existing** [`SkillStore`] port only — no new port. Read-only.
|
||||
pub struct ReadSkill {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
}
|
||||
|
||||
impl ReadSkill {
|
||||
/// Builds the use case from the existing skill store port.
|
||||
#[must_use]
|
||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||
Self { skills }
|
||||
}
|
||||
|
||||
/// Resolves `name` in `scope`, returning the single match.
|
||||
///
|
||||
/// `Ok(None)` ⇒ no skill by that name in this scope; `Err(Invalid)` ⇒ the
|
||||
/// name is **ambiguous** (more than one skill shares it in this scope).
|
||||
async fn resolve_in(
|
||||
&self,
|
||||
scope: SkillScope,
|
||||
input: &ReadSkillInput,
|
||||
) -> Result<Option<Skill>, AppError> {
|
||||
let all = self.skills.list(scope, &input.project_root).await?;
|
||||
let mut matches = all
|
||||
.into_iter()
|
||||
.filter(|s| s.name.eq_ignore_ascii_case(&input.name));
|
||||
match (matches.next(), matches.next()) {
|
||||
(None, _) => Ok(None),
|
||||
(Some(skill), None) => Ok(Some(skill)),
|
||||
(Some(_), Some(_)) => Err(AppError::Invalid(format!(
|
||||
"skill name `{}` is ambiguous in {scope:?} scope (several skills share it)",
|
||||
input.name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the skill by name and returns its Markdown body.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the name is ambiguous within a scope,
|
||||
/// - [`AppError::NotFound`] if no skill carries that name in either scope,
|
||||
/// - [`AppError::Store`] on a store failure.
|
||||
pub async fn execute(&self, input: ReadSkillInput) -> Result<MarkdownDoc, AppError> {
|
||||
// Project scope shadows global: try it first, then fall back to global.
|
||||
if let Some(skill) = self.resolve_in(SkillScope::Project, &input).await? {
|
||||
return Ok(skill.content_md);
|
||||
}
|
||||
if let Some(skill) = self.resolve_in(SkillScope::Global, &input).await? {
|
||||
return Ok(skill.content_md);
|
||||
}
|
||||
Err(AppError::NotFound(format!("skill `{}`", input.name)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AssignSkillToAgent / UnassignSkillFromAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -349,3 +425,185 @@ impl UnassignSkillFromAgent {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{SkillStore, StoreError};
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/proj").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()
|
||||
}
|
||||
|
||||
fn read_skill_uc(store: Arc<FakeSkillStore>) -> ReadSkill {
|
||||
ReadSkill::new(store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_resolves_project_scope() {
|
||||
// (a) skill present in the project scope ⇒ its body is returned.
|
||||
let store = Arc::new(FakeSkillStore::default());
|
||||
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
|
||||
|
||||
let body = read_skill_uc(store)
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body.as_str(), "PROJECT_BODY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_falls_back_to_global_scope() {
|
||||
// (b) absent from project but present globally ⇒ resolved via global.
|
||||
let store = Arc::new(FakeSkillStore::default());
|
||||
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
|
||||
|
||||
let body = read_skill_uc(store)
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body.as_str(), "GLOBAL_BODY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_project_shadows_global() {
|
||||
// (c) same name in both scopes ⇒ project wins.
|
||||
let store = Arc::new(FakeSkillStore::default());
|
||||
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
|
||||
store.push(skill(2, "deploy", "PROJECT_BODY", SkillScope::Project));
|
||||
|
||||
let body = read_skill_uc(store)
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body.as_str(), "PROJECT_BODY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_is_case_insensitive() {
|
||||
let store = Arc::new(FakeSkillStore::default());
|
||||
store.push(skill(1, "Deploy", "BODY", SkillScope::Project));
|
||||
|
||||
let body = read_skill_uc(store)
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body.as_str(), "BODY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_unknown_is_not_found() {
|
||||
// (d) unknown in both scopes ⇒ NotFound.
|
||||
let store = Arc::new(FakeSkillStore::default());
|
||||
let err = read_skill_uc(store)
|
||||
.execute(ReadSkillInput {
|
||||
name: "ghost".to_owned(),
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_ambiguous_name_is_invalid() {
|
||||
// (e) two skills share a name within a scope ⇒ Invalid (ambiguous).
|
||||
let store = Arc::new(FakeSkillStore::default());
|
||||
store.push(skill(1, "deploy", "ONE", SkillScope::Project));
|
||||
store.push(skill(2, "Deploy", "TWO", SkillScope::Project));
|
||||
|
||||
let err = read_skill_uc(store)
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +195,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
let out = CreateSkill::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
|
||||
.execute(CreateSkillInput {
|
||||
name: "refactor".to_owned(),
|
||||
description: Some("Refactors code".to_owned()),
|
||||
content: "# body".to_owned(),
|
||||
scope: SkillScope::Project,
|
||||
project_root: root(),
|
||||
@ -203,6 +204,8 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||
// The one-line affordance description flows through the use case onto the skill.
|
||||
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
|
||||
assert_eq!(
|
||||
store
|
||||
.list(SkillScope::Project, &root())
|
||||
@ -224,6 +227,7 @@ async fn create_skill_rejects_empty_content() {
|
||||
let err = CreateSkill::new(Arc::new(store), Arc::new(SeqIds::new()))
|
||||
.execute(CreateSkillInput {
|
||||
name: "k".to_owned(),
|
||||
description: None,
|
||||
content: String::new(),
|
||||
scope: SkillScope::Global,
|
||||
project_root: root(),
|
||||
|
||||
Reference in New Issue
Block a user