feat(domain,application,infrastructure,backend,app-tauri,frontend): lot utilisateur #350 — animation d'écartement des onglets au drag/reorder, icônes projet N&B colorisées derrière les messages, tools/skills plugin utilisables par les agents autorisés en headless, refs stables des skills assignés (QA verte)
- Tabs (shared/ui): cible de drop matérialisée par écartement before/after pendant le drag/reorder, reset via rAF sans reliquat, clavier et annonces préservés. - projectIcons: icônes projet en noir et blanc par défaut, colorisées avec la couleur du projet, occupant la cellule et visibles derrière les messages rendus légèrement transparents. - Permissions plugin headless: PluginMcpServerSpec transporte l'allowlist exacte authorized_tools regroupée par (pluginId, serverId) depuis les PluginToolRef; projection Codex enabled_tools + default_tools_approval_mode="approve" — une permission déjà accordée au niveau projet/template ne déclenche pas de seconde approbation, un tool non assigné reste hors surface appelable; réserves orchestrator/mcp server+tools alignées, dto backend + dto_agents app-tauri. - Skills: ref stable plugin:<pluginId>:<skillId> exposée dans le contexte agent, idea_skill_read(ref=...) canonique via read_assigned_by_ref, erreur structurée ambiguousSkillName avec candidates; SkillsPanel dédoublonné (android-build-install); agents-dev/L12-skills.md à jour. - Docs: ARCHITECTURE.md section 22.3 (projection des autorisations agent vers les runtimes plugin). - QA verte #350 reconfirmée avant commit.
This commit is contained in:
@ -3801,7 +3801,7 @@ fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> Strin
|
||||
for server in plugin_servers {
|
||||
rendered.push('\n');
|
||||
rendered.push_str(&format!(
|
||||
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\n",
|
||||
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\nenabled_tools = [{}]\ndefault_tools_approval_mode = \"approve\"\n",
|
||||
toml_string(&server.client_name()),
|
||||
toml_string(&server.command),
|
||||
server
|
||||
@ -3812,6 +3812,12 @@ fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> Strin
|
||||
.join(", "),
|
||||
toml_inline_string_map(&server.env),
|
||||
toml_string(&server.cwd),
|
||||
server
|
||||
.authorized_tools
|
||||
.iter()
|
||||
.map(|tool| toml_string(tool))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
));
|
||||
}
|
||||
rendered
|
||||
@ -4468,7 +4474,7 @@ pub(crate) fn compose_convention_file(
|
||||
out.push_str(
|
||||
"**Skills** : workflows réutilisables. Ceux qui te sont assignés te sont listés \
|
||||
plus haut dans ce fichier ; charge le détail d'un skill avec \
|
||||
`idea_skill_read(name=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \
|
||||
`idea_skill_read(ref=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \
|
||||
pas un workflow déjà couvert par un skill.\n\n",
|
||||
);
|
||||
} else {
|
||||
@ -4517,7 +4523,7 @@ pub(crate) fn compose_convention_file(
|
||||
if mcp_enabled {
|
||||
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 \
|
||||
détail, appelle l'outil `idea_skill_read(ref=…)` avec la ref affichée — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
} else {
|
||||
@ -4531,7 +4537,9 @@ pub(crate) fn compose_convention_file(
|
||||
for skill in &effective.agent_capabilities {
|
||||
out.push_str("**");
|
||||
out.push_str(&skill.name);
|
||||
out.push_str("** — ");
|
||||
out.push_str("** (`");
|
||||
out.push_str(&skill.reference);
|
||||
out.push_str("`) — ");
|
||||
out.push_str(&skill.description);
|
||||
out.push_str(" (");
|
||||
out.push_str(match skill.kind {
|
||||
@ -4943,8 +4951,10 @@ mod tests {
|
||||
);
|
||||
|
||||
// Both skill affordances are present, but bodies are not dumped.
|
||||
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
|
||||
assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
|
||||
assert!(doc.contains("**refactor** (`skill:global:"));
|
||||
assert!(doc.contains("`) — REFAC_BODY (workflow)"));
|
||||
assert!(doc.contains("**review** (`skill:global:"));
|
||||
assert!(doc.contains("`) — REVIEW_BODY (workflow)"));
|
||||
assert!(!doc.contains("\n\nREFAC_BODY"));
|
||||
assert!(!doc.contains("\n\nREVIEW_BODY"));
|
||||
let awareness_at = doc.find("**Skills IdeA**").unwrap();
|
||||
@ -5118,8 +5128,10 @@ mod tests {
|
||||
"points to the file-protocol read path"
|
||||
);
|
||||
}
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
assert!(doc.contains("**refactor** (`skill:global:"));
|
||||
assert!(doc.contains("`) — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** (`skill:global:"));
|
||||
assert!(doc.contains("`) — Review skill (workflow)"));
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("## refactor"));
|
||||
@ -5541,6 +5553,7 @@ mod tests {
|
||||
env: vec![("UNITY_SOCKET".to_owned(), "/tmp/unity.sock".to_owned())],
|
||||
cwd: "/plugins/unity".to_owned(),
|
||||
transport: "stdio".to_owned(),
|
||||
authorized_tools: vec!["unity_detect_project".to_owned(), "unity_build".to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
@ -5565,6 +5578,9 @@ mod tests {
|
||||
);
|
||||
assert!(codex.contains(&format!("[mcp_servers.{}]", toml_string(&name))));
|
||||
assert!(codex.contains("UNITY_SOCKET"));
|
||||
assert!(codex.contains("enabled_tools = [\"unity_detect_project\", \"unity_build\"]"));
|
||||
assert!(!codex.contains("unity_delete_project"));
|
||||
assert!(codex.contains("default_tools_approval_mode = \"approve\""));
|
||||
|
||||
let opencode = opencode_mcp_servers(
|
||||
vec!["idea".into(), "mcp-server".into()],
|
||||
|
||||
@ -27,6 +27,15 @@ pub enum AppError {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
/// A legacy skill-name lookup matched more than one assigned capability.
|
||||
#[error("skill name `{name}` is ambiguous; use one of these refs: {candidates:?}")]
|
||||
AmbiguousSkillName {
|
||||
/// Name supplied by the caller.
|
||||
name: String,
|
||||
/// Stable refs of every matching assigned skill.
|
||||
candidates: Vec<String>,
|
||||
},
|
||||
|
||||
/// An optimistic-concurrency `if_match` did not match the resource's current
|
||||
/// version. Carries the current version so the caller can retry.
|
||||
#[error("concurrency conflict: {0}")]
|
||||
@ -150,6 +159,7 @@ impl AppError {
|
||||
match self {
|
||||
Self::NotFound(_) => "NOT_FOUND",
|
||||
Self::Invalid(_) => "INVALID",
|
||||
Self::AmbiguousSkillName { .. } => "ambiguousSkillName",
|
||||
Self::Conflict(_) => "CONFLICT",
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
|
||||
@ -1186,8 +1186,8 @@ 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
|
||||
/// Use case de lecture d'un skill par ref stable ou nom compatible
|
||||
/// (`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>>,
|
||||
@ -2396,9 +2396,11 @@ 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::ReadSkill {
|
||||
reference,
|
||||
name,
|
||||
requester,
|
||||
} => self.read_skill(project, reference, name, requester).await,
|
||||
OrchestratorCommand::WriteMemory {
|
||||
slug,
|
||||
content,
|
||||
@ -2997,14 +2999,15 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `skill.read` → resolves a skill by name (project scope first, then global)
|
||||
/// `skill.read` → resolves an assigned skill by stable ref or legacy name
|
||||
/// 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,
|
||||
reference: Option<String>,
|
||||
name: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let read_skill = self.read_skill.as_deref().ok_or_else(|| {
|
||||
@ -3012,13 +3015,17 @@ impl OrchestratorService {
|
||||
})?;
|
||||
let md = read_skill
|
||||
.execute(ReadSkillInput {
|
||||
reference: reference.clone(),
|
||||
name: name.clone(),
|
||||
project: project.clone(),
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read skill {name}"),
|
||||
detail: format!(
|
||||
"read skill {}",
|
||||
reference.as_deref().or(name.as_deref()).unwrap_or_default()
|
||||
),
|
||||
reply: Some(md.into_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -3649,6 +3649,7 @@ async fn active_mcp_specs(
|
||||
} else {
|
||||
format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command)
|
||||
};
|
||||
let authorized_tools = server.tools.iter().map(|tool| tool.name.clone()).collect();
|
||||
specs.push(PluginMcpServerSpec {
|
||||
identity: format!(
|
||||
"plugin:{}:{}",
|
||||
@ -3680,6 +3681,7 @@ async fn active_mcp_specs(
|
||||
app_data_dir.as_deref(),
|
||||
),
|
||||
transport: server.transport,
|
||||
authorized_tools,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
//! Project-scoped plugin activation and per-agent capability assignments.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
@ -144,12 +143,16 @@ impl ProjectPluginResolver {
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
let app_data_dir = self.packages.app_data_dir_label();
|
||||
let mut seen = HashSet::new();
|
||||
let mut spec_indexes = std::collections::HashMap::new();
|
||||
let mut specs = Vec::new();
|
||||
|
||||
for reference in &assignments.tools {
|
||||
let key = (reference.plugin_id.clone(), reference.server_id.clone());
|
||||
if !seen.insert(key) {
|
||||
if let Some(index) = spec_indexes.get(&key).copied() {
|
||||
let spec: &mut PluginMcpServerSpec = &mut specs[index];
|
||||
if !spec.authorized_tools.contains(&reference.tool_name) {
|
||||
spec.authorized_tools.push(reference.tool_name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(manifest) = self
|
||||
@ -180,6 +183,7 @@ impl ProjectPluginResolver {
|
||||
} else {
|
||||
format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command)
|
||||
};
|
||||
let index = specs.len();
|
||||
specs.push(PluginMcpServerSpec {
|
||||
identity: format!(
|
||||
"plugin:{}:{}",
|
||||
@ -211,7 +215,9 @@ impl ProjectPluginResolver {
|
||||
app_data_dir.as_deref(),
|
||||
),
|
||||
transport: server.transport,
|
||||
authorized_tools: vec![reference.tool_name.clone()],
|
||||
});
|
||||
spec_indexes.insert(key, index);
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
@ -896,8 +902,13 @@ mod tests {
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
tool_name: "unity_detect_project".to_owned(),
|
||||
};
|
||||
let second_tool = PluginToolRef {
|
||||
plugin_id: plugin_id(),
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
tool_name: "unity_build".to_owned(),
|
||||
};
|
||||
let resolver = resolver(
|
||||
settings(true, vec![tool]),
|
||||
settings(true, vec![tool.clone(), second_tool, tool]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
@ -923,6 +934,10 @@ mod tests {
|
||||
vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())]
|
||||
);
|
||||
assert_eq!(server.cwd, "/plugins/unity/work");
|
||||
assert_eq!(
|
||||
server.authorized_tools,
|
||||
vec!["unity_detect_project".to_owned(), "unity_build".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -223,6 +223,9 @@ impl DeleteSkill {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCapability {
|
||||
/// Stable reference accepted by `idea_skill_read(ref=...)`.
|
||||
#[serde(default)]
|
||||
pub reference: String,
|
||||
/// Capability display name.
|
||||
pub name: String,
|
||||
/// One-line affordance description.
|
||||
@ -234,6 +237,7 @@ pub struct AgentCapability {
|
||||
impl From<&AssignedSkillSnapshot> for AgentCapability {
|
||||
fn from(skill: &AssignedSkillSnapshot) -> Self {
|
||||
Self {
|
||||
reference: skill.stable_ref(),
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
kind: skill.kind,
|
||||
@ -415,6 +419,22 @@ impl AssignedSkillResolver {
|
||||
Ok(assigned)
|
||||
}
|
||||
|
||||
/// Reads the Markdown body for one skill by stable ref, but only inside the supplied
|
||||
/// assigned-skill snapshot.
|
||||
pub async fn read_assigned_by_ref(
|
||||
&self,
|
||||
snapshot: &OrchestrationCapabilitySnapshot,
|
||||
root: &ProjectPath,
|
||||
reference: &str,
|
||||
) -> Result<MarkdownDoc, AppError> {
|
||||
let selected = snapshot
|
||||
.assigned_skills
|
||||
.iter()
|
||||
.find(|skill| skill.stable_ref() == reference)
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned skill ref `{reference}`")))?;
|
||||
self.read_assigned_snapshot(selected, root).await
|
||||
}
|
||||
|
||||
/// Reads the Markdown body for one skill by name, but only inside the supplied
|
||||
/// assigned-skill snapshot.
|
||||
///
|
||||
@ -435,27 +455,41 @@ impl AssignedSkillResolver {
|
||||
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"
|
||||
)))
|
||||
(Some(first), Some(second)) => {
|
||||
let mut candidates = vec![first.stable_ref(), second.stable_ref()];
|
||||
candidates.extend(matches.map(AssignedSkillSnapshot::stable_ref));
|
||||
return Err(AppError::AmbiguousSkillName {
|
||||
name: name.to_owned(),
|
||||
candidates,
|
||||
});
|
||||
}
|
||||
};
|
||||
self.read_assigned_snapshot(selected, root).await
|
||||
}
|
||||
|
||||
async fn read_assigned_snapshot(
|
||||
&self,
|
||||
selected: &AssignedSkillSnapshot,
|
||||
root: &ProjectPath,
|
||||
) -> Result<MarkdownDoc, AppError> {
|
||||
if let Some(plugin) = &selected.plugin {
|
||||
let resolver = self
|
||||
.plugin_skills
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
||||
let resolver = self.plugin_skills.as_ref().ok_or_else(|| {
|
||||
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
|
||||
})?;
|
||||
let manifest = resolver
|
||||
.enabled_manifest(root, &plugin.plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
|
||||
})?;
|
||||
let contribution = manifest
|
||||
.contributes
|
||||
.skills
|
||||
.iter()
|
||||
.find(|s| s.id == plugin.skill_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
|
||||
})?;
|
||||
return resolver
|
||||
.read_skill_body(&plugin.plugin_id, &contribution.path)
|
||||
.await;
|
||||
@ -471,8 +505,10 @@ impl AssignedSkillResolver {
|
||||
/// Input for [`ReadSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadSkillInput {
|
||||
/// Skill display name to resolve (case-insensitive).
|
||||
pub name: String,
|
||||
/// Stable assigned-skill reference. Takes precedence over `name`.
|
||||
pub reference: Option<String>,
|
||||
/// Legacy skill display name to resolve (case-insensitive).
|
||||
pub name: Option<String>,
|
||||
/// Active project.
|
||||
pub project: Project,
|
||||
/// Current requester identity from the orchestration handshake.
|
||||
@ -532,9 +568,19 @@ impl ReadSkill {
|
||||
.resolver
|
||||
.resolve_for_agent(&agent, &input.project.root)
|
||||
.await?;
|
||||
self.resolver
|
||||
.read_assigned_by_name(&snapshot, &input.project.root, &input.name)
|
||||
.await
|
||||
if let Some(reference) = input.reference.as_deref() {
|
||||
self.resolver
|
||||
.read_assigned_by_ref(&snapshot, &input.project.root, reference)
|
||||
.await
|
||||
} else if let Some(name) = input.name.as_deref() {
|
||||
self.resolver
|
||||
.read_assigned_by_name(&snapshot, &input.project.root, name)
|
||||
.await
|
||||
} else {
|
||||
Err(AppError::Invalid(
|
||||
"idea_skill_read requires `ref` or `name`".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -929,7 +975,35 @@ mod tests {
|
||||
|
||||
let body = uc
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
reference: None,
|
||||
name: Some("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_skill_by_stable_ref() {
|
||||
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 {
|
||||
reference: Some("skill:project:00000000-0000-0000-0000-000000000001".to_owned()),
|
||||
name: None,
|
||||
project,
|
||||
requester: ConversationParty::agent(requester),
|
||||
})
|
||||
@ -955,7 +1029,8 @@ mod tests {
|
||||
|
||||
let body = uc
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
reference: None,
|
||||
name: Some("deploy".to_owned()),
|
||||
project,
|
||||
requester: ConversationParty::agent(requester),
|
||||
})
|
||||
@ -975,7 +1050,8 @@ mod tests {
|
||||
|
||||
let err = uc
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
reference: None,
|
||||
name: Some("deploy".to_owned()),
|
||||
project,
|
||||
requester: ConversationParty::agent(requester),
|
||||
})
|
||||
@ -1001,7 +1077,8 @@ mod tests {
|
||||
|
||||
let body = uc
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
reference: None,
|
||||
name: Some("deploy".to_owned()),
|
||||
project,
|
||||
requester: ConversationParty::agent(requester),
|
||||
})
|
||||
@ -1019,7 +1096,8 @@ mod tests {
|
||||
|
||||
let err = uc
|
||||
.execute(ReadSkillInput {
|
||||
name: "ghost".to_owned(),
|
||||
reference: None,
|
||||
name: Some("ghost".to_owned()),
|
||||
project,
|
||||
requester: ConversationParty::User,
|
||||
})
|
||||
@ -1052,12 +1130,69 @@ mod tests {
|
||||
|
||||
let err = uc
|
||||
.execute(ReadSkillInput {
|
||||
name: "deploy".to_owned(),
|
||||
reference: None,
|
||||
name: Some("deploy".to_owned()),
|
||||
project,
|
||||
requester: ConversationParty::agent(requester),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
AppError::AmbiguousSkillName { ref name, ref candidates }
|
||||
if name == "deploy"
|
||||
&& *candidates == vec![
|
||||
"skill:project:00000000-0000-0000-0000-000000000001".to_owned(),
|
||||
"skill:global:00000000-0000-0000-0000-000000000002".to_owned(),
|
||||
]
|
||||
),
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_plugin_skill_name_reports_stable_plugin_refs() {
|
||||
let resolver = AssignedSkillResolver::new(Arc::new(FakeSkillStore::default()));
|
||||
let snapshot = OrchestrationCapabilitySnapshot::new(vec![
|
||||
AssignedSkillSnapshot {
|
||||
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
|
||||
scope: SkillScope::Project,
|
||||
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
|
||||
plugin_id: domain::PluginId::new("dev.idea.android").unwrap(),
|
||||
skill_id: domain::PluginSkillId::new("build-install").unwrap(),
|
||||
}),
|
||||
name: "android-build-install".to_owned(),
|
||||
description: "Android".to_owned(),
|
||||
kind: SkillKind::Workflow,
|
||||
assignment_index: 0,
|
||||
},
|
||||
AssignedSkillSnapshot {
|
||||
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
|
||||
scope: SkillScope::Project,
|
||||
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
|
||||
plugin_id: domain::PluginId::new("dev.idea.unity").unwrap(),
|
||||
skill_id: domain::PluginSkillId::new("android-build-install").unwrap(),
|
||||
}),
|
||||
name: "android-build-install".to_owned(),
|
||||
description: "Unity Android".to_owned(),
|
||||
kind: SkillKind::Workflow,
|
||||
assignment_index: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
let error = resolver
|
||||
.read_assigned_by_name(&snapshot, &project().root, "android-build-install")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), "ambiguousSkillName");
|
||||
assert!(matches!(
|
||||
error,
|
||||
AppError::AmbiguousSkillName { ref candidates, .. }
|
||||
if *candidates == vec![
|
||||
"plugin:dev.idea.android:build-install".to_owned(),
|
||||
"plugin:dev.idea.unity:android-build-install".to_owned(),
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user