feat(plugins): hot reload des plugins sans relancer IdeA — use case ReloadPlugin (invalidation registry/catalogue, resultat hashes+changed), tools MCP d'administration plugin agent-facing (mcp/plugins.rs + PluginToolProvider), evenement domaine plugin_reloaded relaye sur le wire (dto/events), exposition openai_tools rafraichie, wiring app-tauri — #271/#269 (QA verte #273: application 32, mcp_server 38, openai_tools 10, plugin_install_load 5, plugin 10, wire 1 — 96 tests ciblés; app-tauri compile sans desktop runtime)

This commit is contained in:
2026-09-07 19:29:25 +02:00
parent 8d2be75f8f
commit 805b1b4d0d
14 changed files with 863 additions and 32 deletions

View File

@ -59,7 +59,8 @@ use application::{
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
use infrastructure::orchestrator::mcp::{
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
PluginToolError, PluginToolProvider, TemplateToolError, TemplateToolProvider, TicketToolError,
TicketToolProvider,
};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
@ -631,6 +632,31 @@ struct FakeTemplateTools {
mutation_attempts: Arc<Mutex<usize>>,
}
#[derive(Clone, Default)]
struct FakePluginTools {
calls: Arc<Mutex<Vec<(String, String, Value)>>>,
}
#[async_trait]
impl PluginToolProvider for FakePluginTools {
async fn handle_plugin_tool(
&self,
_project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, PluginToolError> {
self.calls
.lock()
.unwrap()
.push((requester.to_owned(), name.to_owned(), arguments.clone()));
Ok(json!({
"plugin": { "id": arguments["pluginId"] },
"changed": true
}))
}
}
impl FakeTemplateTools {
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
@ -774,6 +800,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_template_create",
"idea_template_update",
"idea_template_delete",
// Public plugin administration tools.
"idea_plugin_reload",
] {
assert!(
names.contains(&expected),
@ -783,8 +811,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
47,
"exactly the forty-seven exposed idea_* tools; got {names:?}"
48,
"exactly the forty-eight exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -897,6 +925,7 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
assert!(!names.contains(&"idea_template_create"));
assert!(!names.contains(&"idea_template_update"));
assert!(!names.contains(&"idea_template_delete"));
assert!(!names.contains(&"idea_plugin_reload"));
}
// ---------------------------------------------------------------------------
@ -1006,6 +1035,43 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
assert_eq!(template_tools.mutation_attempts(), 0);
}
#[tokio::test]
async fn explicitly_allowed_plugin_reload_routes_to_provider_with_camel_case_arguments() {
let (service, _s) = build_service(FakeContexts::new());
let plugin_tools = Arc::new(FakePluginTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(271));
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_plugin_reload"]))
.with_plugin_tools(plugin_tools.clone())
.for_requester(agent.to_string());
let response = server
.handle_raw(&tools_call(
271,
"idea_plugin_reload",
json!({
"pluginId": "dev.acme.gitgraph",
"expectedContentHash": "abc123"
}),
))
.await
.expect("reply owed");
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("tool result");
assert_eq!(result["isError"], json!(false), "got {result}");
let payload: Value = serde_json::from_str(result_text(&result)).unwrap();
assert_eq!(payload["plugin"]["id"], json!("dev.acme.gitgraph"));
let calls = plugin_tools.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, agent.to_string());
assert_eq!(calls[0].1, "idea_plugin_reload");
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
}
#[tokio::test]
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());