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:
2026-09-13 14:11:31 +02:00
parent 1e15bf9f96
commit 92d0dce2b7
23 changed files with 660 additions and 95 deletions

View File

@ -759,7 +759,17 @@ impl McpServer {
// (`isError: true`) rather than a protocol error: the agent can read
// it and decide, and the connection stays healthy.
Err(e) => {
let detail = e.to_string();
let detail = match &e {
application::AppError::AmbiguousSkillName { candidates, .. } => {
serde_json::json!({
"code": e.code(),
"message": e.to_string(),
"candidates": candidates,
})
.to_string()
}
_ => e.to_string(),
};
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",

View File

@ -521,16 +521,21 @@ pub fn catalogue() -> Vec<ToolDef> {
},
ToolDef {
name: "idea_skill_read",
description: "Read the full body of an IdeA skill assigned to you, by name — call this \
description: "Read the full body of an IdeA skill assigned to you, preferably by its stable ref. \
Name lookup remains available for compatibility and fails when ambiguous — call this \
to actually execute/follow a skill listed in your « Skills disponibles » \
section. Resolves project scope first, then global. Returns the skill's \
section. Returns the skill's \
Markdown inline.",
input_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Skill name (as listed in your context)." }
"ref": { "type": "string", "description": "Stable skill ref (for example plugin:<pluginId>:<skillId>)." },
"name": { "type": "string", "description": "Legacy skill name lookup (must be unambiguous)." }
},
"required": ["name"],
"anyOf": [
{ "required": ["ref"] },
{ "required": ["name"] }
],
"additionalProperties": false
}),
},
@ -767,6 +772,7 @@ pub fn map_tool_call(
// The requester (handshake identity) is carried for symmetry with the
// other read tools; `validate` derives the requester party from it.
requested_by: Some(requester.to_owned()),
skill_ref: s("ref"),
name: s("name"),
..base()
},
@ -809,6 +815,7 @@ fn base() -> OrchestratorRequest {
request_type: None,
requested_by: None,
name: None,
skill_ref: None,
target_agent: None,
profile: None,
context: None,
@ -1450,6 +1457,48 @@ mod tests {
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn skill_read_prefers_stable_ref_and_keeps_name_compatibility() {
let requester = domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap(),
));
assert_eq!(
map_tool_call(
"idea_skill_read",
&json!({ "ref": "plugin:dev.idea.android:build-install" }),
REQ,
)
.unwrap(),
OrchestratorCommand::ReadSkill {
reference: Some("plugin:dev.idea.android:build-install".to_owned()),
name: None,
requester: requester.clone(),
}
);
assert_eq!(
map_tool_call("idea_skill_read", &json!({ "name": "build" }), REQ).unwrap(),
OrchestratorCommand::ReadSkill {
reference: None,
name: Some("build".to_owned()),
requester,
}
);
assert!(matches!(
map_tool_call("idea_skill_read", &json!({}), REQ),
Err(ToolMapError::Invalid(_))
));
let tool = catalogue()
.into_iter()
.find(|tool| tool.name == "idea_skill_read")
.unwrap();
assert!(tool.input_schema["properties"].get("ref").is_some());
assert_eq!(
tool.input_schema["anyOf"],
json!([{ "required": ["ref"] }, { "required": ["name"] }])
);
}
#[test]
fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
assert!(tool_returns_reply("idea_ask_agent"));

View File

@ -890,6 +890,7 @@ mod tests {
env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())],
cwd: "/plugin".to_owned(),
transport: "stdio".to_owned(),
authorized_tools: Vec::new(),
}
}