From 8bfcb3a534dc627872ac8292e2e2b503d508ed89 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 8 Sep 2026 17:35:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(orchestrator,plugins):=20contact=20cross-pr?= =?UTF-8?q?oject=20corr=C3=A9l=C3=A9=20+=20d=C3=A9couvrabilit=C3=A9=20assi?= =?UTF-8?q?gnation=20plugin=20par=20agent=20=E2=80=94=20#284=20(backend:?= =?UTF-8?q?=20registre=20des=20demandes=20actives=20cl=C3=A9=20projet/agen?= =?UTF-8?q?t=20appelant=20+=20correlationId,=20refus=20corr=C3=A9lation=20?= =?UTF-8?q?vide/dupliqu=C3=A9e,=20cancel=20rout=C3=A9=20apr=C3=A8s=20corre?= =?UTF-8?q?spondance=20exacte=20sans=20redirection=20vers=20une=20autre=20?= =?UTF-8?q?cible,=20appelant=20projet=C3=A9=20Waiting=E2=86=92Done=20dans?= =?UTF-8?q?=20le=20live-state=20de=20son=20propre=20projet=20avec=20correl?= =?UTF-8?q?ationId=20expos=C3=A9=20par=20idea=5Fworkstate=5Fread,=20gardes?= =?UTF-8?q?=20RAII=20r=C3=A9concilient=20l'abandon=20du=20futur,=20tools?= =?UTF-8?q?=20ask/cancel=20WriteAction=20deny-by-default=20dont=20le=20ref?= =?UTF-8?q?us=20indique=20o=C3=B9=20activer=20la=20permission;=20frontend:?= =?UTF-8?q?=20action=20Attribuer=20aux=20agents=20depuis=20Settings=20proj?= =?UTF-8?q?et=20>=20Plugins=20ouvrant=20le=20panneau=20Agents=20flottant/a?= =?UTF-8?q?ncr=C3=A9/d=C3=A9tach=C3=A9,=20section=20Capabilities=20de=20pl?= =?UTF-8?q?ugins=20=E2=80=94=20{agent}=20explicitant=20attribution=20skill?= =?UTF-8?q?s=20et=20tools=20MCP=20des=20plugins=20activ=C3=A9s;=20QA=20ver?= =?UTF-8?q?te:=20orchestrator=5Fservice=2079/79,=20mcp=5Fserver=2040/40,?= =?UTF-8?q?=20domain=20live=5Fstate=2015/15,=20correlated=5Fupdate=5Fis=5F?= =?UTF-8?q?preserved=5Fin=5Fthe=5Flean=5Fprojection=201/1,=20frontend=20Pr?= =?UTF-8?q?ojectPluginSettings=203=20tests,=20npm=20run=20build=20frontend?= =?UTF-8?q?,=20cargo=20fmt=20--check,=20git=20diff=20--check;=20AppImage?= =?UTF-8?q?=20IdeA=5F0.3.0=5Famd64.AppImage=20rebuild=C3=A9e=20workflow=20?= =?UTF-8?q?build-appimage,=20validation=20live=20cross-project=20restant?= =?UTF-8?q?=20apr=C3=A8s=20remplacement/relance=20manuels;=20r=C3=A9serves?= =?UTF-8?q?:=20registre=20corr=C3=A9lation=20en=20m=C3=A9moire=20non=20per?= =?UTF-8?q?sist=C3=A9,=20surface=20web=20unsupported,=20catalogue=20dynami?= =?UTF-8?q?que=20tools=20MCP=20=3D=20r=C3=A9serve=20#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/src/orchestrator/service.rs | 283 +++++++++++++++++- crates/application/src/workstate/live.rs | 49 +++ .../application/tests/orchestrator_service.rs | 132 +++++++- crates/domain/src/live_state.rs | 30 ++ .../src/orchestrator/mcp/server.rs | 7 +- .../src/orchestrator/mcp/tools.rs | 4 +- crates/infrastructure/tests/mcp_server.rs | 54 ++++ .../plugins/AgentPluginCapabilities.tsx | 16 +- .../src/features/plugins/PluginsPanel.tsx | 5 +- .../plugins/ProjectPluginSettings.test.tsx | 19 +- .../features/plugins/ProjectPluginsPanel.tsx | 27 +- .../src/features/projects/ProjectsView.tsx | 6 + .../src/features/settings/SettingsView.tsx | 7 +- 13 files changed, 604 insertions(+), 35 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 5e20ba71..ea4f6b3d 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -401,6 +401,68 @@ impl Drop for LiveStateTurnGuard { } } +/// Drop-safe caller-side live-state cleanup for a cross-project request. +struct CrossProjectLiveStateGuard { + update: Option>, + requester: AgentId, + correlation_id: String, + armed: bool, +} + +impl CrossProjectLiveStateGuard { + fn new( + update: Option>, + requester: AgentId, + correlation_id: String, + ) -> Self { + Self { + update, + requester, + correlation_id, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for CrossProjectLiveStateGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let Some(update) = self.update.clone() else { + return; + }; + let requester = self.requester; + let correlation_id = self.correlation_id.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + if let Err(error) = update + .execute_correlated( + UpdateLiveStateInput { + agent_id: requester, + ticket: None, + intent: String::new(), + status: domain::live_state::WorkStatus::Done, + progress: None, + last_delegation: None, + }, + correlation_id.clone(), + ) + .await + { + crate::diag!( + "[cross-project] caller drop cleanup failed: requester={requester} correlation={correlation_id} err={error}" + ); + } + }); + } + } +} + /// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP /// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans /// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint). @@ -513,6 +575,9 @@ pub struct OrchestratorService { /// attach `idea_run_in_background` tasks for observability/cancellation while the /// synchronous answer remains the first structured `Final`. active_rendezvous: StdMutex>, + /// Active cross-project calls keyed by caller identity and caller-supplied + /// correlation id. Cancel uses this registry as its routing authority. + cross_project_requests: StdMutex>, /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de /// publication (l'`ask` fonctionne quand même). @@ -653,6 +718,19 @@ struct ActiveRendezvous { background_tasks: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CrossProjectRequestKey { + requester_project_id: domain::ProjectId, + requester_agent_id: AgentId, + correlation_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ActiveCrossProjectRequest { + target_project_id: domain::ProjectId, + target_agent_id: AgentId, +} + impl OrchestratorService { /// Builds the service from the use cases and ports it dispatches to. #[must_use] @@ -683,6 +761,7 @@ impl OrchestratorService { wait_for: StdMutex::new(WaitForGraph::new()), active_waits: StdMutex::new(Vec::new()), active_rendezvous: StdMutex::new(HashMap::new()), + cross_project_requests: StdMutex::new(HashMap::new()), events: None, ask_locks: StdMutex::new(HashMap::new()), mcp_runtime_provider: None, @@ -1216,6 +1295,91 @@ impl OrchestratorService { } } + async fn mark_cross_project_requester_best_effort( + &self, + root: &ProjectPath, + requester: AgentId, + status: domain::live_state::WorkStatus, + intent: String, + correlation_id: String, + ) { + let Some(provider) = &self.live_state else { + return; + }; + let Some(update) = provider.live_state_for(root) else { + return; + }; + if let Err(error) = update + .execute_correlated( + UpdateLiveStateInput { + agent_id: requester, + ticket: None, + intent, + status, + progress: None, + last_delegation: None, + }, + correlation_id.clone(), + ) + .await + { + crate::diag!( + "[cross-project] caller live-state update failed: requester={requester} correlation={correlation_id} err={error}" + ); + } + } + + fn register_cross_project_request( + &self, + requester_project_id: domain::ProjectId, + requester_agent_id: AgentId, + correlation_id: String, + target_project_id: domain::ProjectId, + target_agent_id: AgentId, + ) -> Result, AppError> { + if correlation_id.trim().is_empty() { + return Err(AppError::Invalid( + "cross-project correlationId must not be empty".to_owned(), + )); + } + if correlation_id.len() > domain::live_state::FIELD_MAX_BYTES { + return Err(AppError::Invalid(format!( + "cross-project correlationId exceeds {} bytes", + domain::live_state::FIELD_MAX_BYTES + ))); + } + let key = CrossProjectRequestKey { + requester_project_id, + requester_agent_id, + correlation_id, + }; + let target = ActiveCrossProjectRequest { + target_project_id, + target_agent_id, + }; + let mut active = self + .cross_project_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = active.get(&key) { + return Err(AppError::Invalid(format!( + "cross-project correlationId `{}` is already active for requester {}/{} (target {}/{})", + key.correlation_id, + key.requester_project_id, + key.requester_agent_id, + existing.target_project_id, + existing.target_agent_id + ))); + } + active.insert(key.clone(), target); + drop(active); + Ok(CrossProjectRequestGuard { + active: &self.cross_project_requests, + key, + target, + }) + } + /// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`. /// /// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider @@ -1460,6 +1624,18 @@ impl OrchestratorService { requester_agent_id: AgentId, correlation_id: String, ) -> Result { + let requester_project = self + .load_project_for_cross_tool(requester_project_id) + .await?; + if self + .find_agent_by_id(&requester_project, requester_agent_id) + .await? + .is_none() + { + return Err(AppError::NotFound(format!( + "requester agent {requester_agent_id} in project {requester_project_id}" + ))); + } let project = self.load_project_for_cross_tool(target_project_id).await?; let target = self .find_agent_by_id(&project, target_agent_id) @@ -1470,11 +1646,46 @@ impl OrchestratorService { "agent {target_agent_id} is not contactable cross-project" ))); } + let _request_guard = self.register_cross_project_request( + requester_project_id, + requester_agent_id, + correlation_id.clone(), + target_project_id, + target_agent_id, + )?; crate::diag!( "[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}" ); - self.ask_resolved_agent(&project, target, task, Some(requester_agent_id)) - .await + self.mark_cross_project_requester_best_effort( + &requester_project.root, + requester_agent_id, + domain::live_state::WorkStatus::Waiting, + Self::distill_intent(&format!("Waiting for {} in another project", target.name)), + correlation_id.clone(), + ) + .await; + let caller_update = self + .live_state + .as_ref() + .and_then(|provider| provider.live_state_for(&requester_project.root)); + let mut caller_live_guard = CrossProjectLiveStateGuard::new( + caller_update, + requester_agent_id, + correlation_id.clone(), + ); + let result = self + .ask_resolved_agent(&project, target, task, Some(requester_agent_id)) + .await; + self.mark_cross_project_requester_best_effort( + &requester_project.root, + requester_agent_id, + domain::live_state::WorkStatus::Done, + String::new(), + correlation_id, + ) + .await; + caller_live_guard.disarm(); + result } /// Cancels/stops one contactable agent in another project by stable ids. @@ -1486,22 +1697,47 @@ impl OrchestratorService { requester_agent_id: AgentId, correlation_id: String, ) -> Result { - let project = self.load_project_for_cross_tool(target_project_id).await?; - let target = self - .find_agent_by_id(&project, target_agent_id) - .await? - .ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?; - if !target.contactable { + let key = CrossProjectRequestKey { + requester_project_id, + requester_agent_id, + correlation_id: correlation_id.clone(), + }; + let active = self + .cross_project_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&key) + .copied() + .ok_or_else(|| { + AppError::NotFound(format!( + "active cross-project request with correlationId `{correlation_id}` for requester {requester_project_id}/{requester_agent_id}" + )) + })?; + if active.target_project_id != target_project_id + || active.target_agent_id != target_agent_id + { return Err(AppError::Invalid(format!( - "agent {target_agent_id} is not contactable cross-project" + "cross-project correlationId `{correlation_id}` belongs to target {}/{}, not {target_project_id}/{target_agent_id}", + active.target_project_id, active.target_agent_id ))); } + let project = self + .load_project_for_cross_tool(active.target_project_id) + .await?; + let target = self + .find_agent_by_id(&project, active.target_agent_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("agent {}", active.target_agent_id)))?; crate::diag!( "[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}" ); - let _ = requester_agent_id; - self.stop_agent_by_id(&project, target.id, target.name, None) - .await + let mut outcome = self + .stop_agent_by_id(&project, target.id, target.name, None) + .await?; + outcome.detail = format!( + "cancelled cross-project request correlationId `{correlation_id}` for target {target_project_id}/{target_agent_id}" + ); + Ok(outcome) } async fn run_in_background( @@ -1714,7 +1950,7 @@ impl OrchestratorService { /// name, returned inline as a JSON array. Entries whose agent is no longer in the /// manifest are dropped; `progress` is **never** exposed (the lean DTO has none). /// - /// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`. + /// Shape: `[{"agent","status","intent","ticket?","lastDelegation?","correlationId?"}]`. async fn read_workstate( &self, project: &Project, @@ -1756,6 +1992,9 @@ impl OrchestratorService { if let Some(last) = entry.last_delegation { row["lastDelegation"] = serde_json::json!(last); } + if let Some(correlation_id) = entry.correlation_id { + row["correlationId"] = serde_json::json!(correlation_id); + } Some(row) }) .collect(); @@ -3440,6 +3679,24 @@ impl Drop for ActiveRendezvousGuard<'_> { } } +struct CrossProjectRequestGuard<'a> { + active: &'a StdMutex>, + key: CrossProjectRequestKey, + target: ActiveCrossProjectRequest, +} + +impl Drop for CrossProjectRequestGuard<'_> { + fn drop(&mut self) { + let mut active = self + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active.get(&self.key) == Some(&self.target) { + active.remove(&self.key); + } + } +} + /// Normalises a profile reference for tolerant matching: lowercased, with spaces, /// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"` /// → comparable forms; `claude` ⊂ ... handled by the command match above). diff --git a/crates/application/src/workstate/live.rs b/crates/application/src/workstate/live.rs index b7f32aeb..65a411cd 100644 --- a/crates/application/src/workstate/live.rs +++ b/crates/application/src/workstate/live.rs @@ -85,6 +85,24 @@ impl UpdateLiveState { /// [`AppError::Invalid`] if a text field exceeds the domain hard threshold; /// [`AppError::Store`] on a persistence failure. pub async fn execute(&self, input: UpdateLiveStateInput) -> Result<(), AppError> { + self.execute_with_correlation(input, None).await + } + + /// Publishes a transition carrying an external cross-project correlation id. + pub async fn execute_correlated( + &self, + input: UpdateLiveStateInput, + correlation_id: String, + ) -> Result<(), AppError> { + self.execute_with_correlation(input, Some(correlation_id)) + .await + } + + async fn execute_with_correlation( + &self, + input: UpdateLiveStateInput, + correlation_id: Option, + ) -> Result<(), AppError> { let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0); let entry = LiveEntry::new( input.agent_id, @@ -95,6 +113,7 @@ impl UpdateLiveState { input.last_delegation, now_ms, ) + .and_then(|entry| entry.with_correlation_id(correlation_id)) .map_err(|e| AppError::Invalid(e.to_string()))?; self.store.upsert(entry).await?; Ok(()) @@ -117,6 +136,9 @@ pub struct LeanLiveEntry { pub ticket: Option, /// The ticket of the most recent delegation it issued, if any. pub last_delegation: Option, + /// External correlation for an active/completed cross-project request. + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, } impl From for LeanLiveEntry { @@ -127,6 +149,7 @@ impl From for LeanLiveEntry { status: e.status, ticket: e.ticket, last_delegation: e.last_delegation, + correlation_id: e.correlation_id, } } } @@ -246,6 +269,32 @@ mod tests { ); } + #[tokio::test] + async fn correlated_update_is_preserved_in_the_lean_projection() { + let store = Arc::new(InMemoryLiveStateStore::default()); + let uc = UpdateLiveState::new(store.clone(), Arc::new(FixedClock(4_242))); + + uc.execute_correlated( + UpdateLiveStateInput { + agent_id: aid(1), + ticket: None, + intent: "waiting for another project".to_owned(), + status: WorkStatus::Waiting, + progress: None, + last_delegation: None, + }, + "cross-284".to_owned(), + ) + .await + .unwrap(); + + let lean = GetLiveStateLean::new(store, Arc::new(FixedClock(4_242))) + .execute() + .await + .unwrap(); + assert_eq!(lean.entries[0].correlation_id.as_deref(), Some("cross-284")); + } + #[tokio::test] async fn update_applies_domain_bounds_truncation() { let store = Arc::new(InMemoryLiveStateStore::default()); diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 069aab77..1fbd0561 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -902,7 +902,10 @@ async fn cross_project_list_agents_filters_to_contactable_agents() { let target_project = other_project(); let svc = fx .service - .with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()]))); + .with_project_store(Arc::new(FakeProjects::new(vec![ + project(), + target_project.clone(), + ]))); let out = svc .list_project_agents_for_mcp(target_project.id) @@ -919,11 +922,17 @@ async fn cross_project_list_agents_filters_to_contactable_agents() { #[tokio::test] async fn cross_project_ask_rejects_non_contactable_agent_before_launch() { let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md"); - let fx = fixture(FakeContexts::with_agent(&hidden, "# hidden")); + let requester = scratch_agent(aid(99), "Requester", "agents/requester.md"); + let contexts = FakeContexts::with_agent(&hidden, "# hidden"); + contexts.add_agent(&requester, "# requester"); + let fx = fixture(contexts); let target_project = other_project(); let svc = fx .service - .with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()]))); + .with_project_store(Arc::new(FakeProjects::new(vec![ + project(), + target_project.clone(), + ]))); let err = svc .ask_project_agent_for_mcp( @@ -950,6 +959,10 @@ async fn cross_project_ask_routes_by_agent_id_when_names_collide() { scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true); let contexts = FakeContexts::with_agent(&first, "# first"); contexts.add_agent(&second, "# second"); + contexts.add_agent( + &scratch_agent(aid(99), "Requester", "agents/requester.md"), + "# requester", + ); let fx = ask_fixture(contexts); let target_project = other_project(); @@ -989,6 +1002,10 @@ async fn cross_project_cancel_routes_by_agent_id_when_names_collide() { scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true); let contexts = FakeContexts::with_agent(&first, "# first"); contexts.add_agent(&second, "# second"); + contexts.add_agent( + &scratch_agent(aid(99), "Requester", "agents/requester.md"), + "# requester", + ); let fx = ask_fixture(contexts); let target_project = other_project(); let first_cancelled = Arc::new(Mutex::new(0_u32)); @@ -1012,7 +1029,27 @@ async fn cross_project_cancel_routes_by_agent_id_when_names_collide() { fx.structured .insert_in_project(target_project.id, second_session, second.id, nid(9102)); - timeout( + let ask_service = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { + ask_service + .ask_project_agent_for_mcp( + target_project.id, + second.id, + "work".to_owned(), + project().id, + aid(99), + "corr-homonym-cancel".to_owned(), + ) + .await + }); + await_until(|| { + fx.live + .entry_for(aid(99)) + .is_some_and(|entry| entry.status == WorkStatus::Waiting) + }) + .await; + + let cancelled = timeout( TEST_GUARD, fx.service.cancel_project_agent_for_mcp( target_project.id, @@ -1026,8 +1063,95 @@ async fn cross_project_cancel_routes_by_agent_id_when_names_collide() { .expect("cross-project cancel must not hang") .expect("cross-project cancel ok"); + assert!(cancelled.detail.contains("corr-homonym-cancel")); assert_eq!(*first_cancelled.lock().unwrap(), 0); assert_eq!(*second_cancelled.lock().unwrap(), 1); + + fx.mailbox + .completions() + .push(second.id, TestCompletion::Cancelled); + let _ = timeout(TEST_GUARD, ask) + .await + .expect("cancelled ask must settle") + .expect("ask join"); +} + +#[tokio::test] +async fn cross_project_ask_projects_correlation_on_requester_and_rejects_wrong_cancel() { + let target = scratch_agent(aid(2), "Target", "agents/target.md").with_contactable(true); + let requester = scratch_agent(aid(99), "Requester", "agents/requester.md"); + let contexts = FakeContexts::with_agent(&target, "# target"); + contexts.add_agent(&requester, "# requester"); + let fx = ask_fixture(contexts); + let target_project = other_project(); + + let ask_service = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { + ask_service + .ask_project_agent_for_mcp( + target_project.id, + target.id, + "work".to_owned(), + project().id, + requester.id, + "corr-visible".to_owned(), + ) + .await + }); + + await_until(|| { + fx.live.entry_for(requester.id).is_some_and(|entry| { + entry.status == WorkStatus::Waiting + && entry.correlation_id.as_deref() == Some("corr-visible") + }) + }) + .await; + let workstate = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::agent(requester.id), + }, + ) + .await + .expect("caller workstate readable"); + let rows: serde_json::Value = + serde_json::from_str(workstate.reply.as_deref().unwrap()).unwrap(); + let requester_row = rows + .as_array() + .unwrap() + .iter() + .find(|row| row["agent"] == "Requester") + .expect("requester projection"); + assert_eq!(requester_row["status"], "waiting"); + assert_eq!(requester_row["correlationId"], "corr-visible"); + + let mismatch = fx + .service + .cancel_project_agent_for_mcp( + target_project.id, + aid(1), + project().id, + requester.id, + "corr-visible".to_owned(), + ) + .await + .expect_err("correlation must not be redirectable to another target"); + assert!(mismatch.to_string().contains("belongs to target")); + + fx.mailbox + .completions() + .push(target.id, TestCompletion::Replied("done".to_owned())); + let out = timeout(TEST_GUARD, ask) + .await + .expect("ask settles") + .expect("ask join") + .expect("ask succeeds"); + assert_eq!(out.reply.as_deref(), Some("done")); + let caller = fx.live.entry_for(requester.id).expect("caller row remains"); + assert_eq!(caller.status, WorkStatus::Done); + assert_eq!(caller.correlation_id.as_deref(), Some("corr-visible")); } #[tokio::test] diff --git a/crates/domain/src/live_state.rs b/crates/domain/src/live_state.rs index c6c55bd5..92f485af 100644 --- a/crates/domain/src/live_state.rs +++ b/crates/domain/src/live_state.rs @@ -106,6 +106,9 @@ pub struct LiveEntry { pub progress: Option, /// The ticket of the most recent delegation this agent issued, if any. pub last_delegation: Option, + /// External correlation carried by an active cross-project request, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, /// Wall-clock update time in epoch milliseconds (drives `prune` ordering). pub updated_at_ms: u64, } @@ -138,9 +141,35 @@ impl LiveEntry { status, progress, last_delegation, + correlation_id: None, updated_at_ms, }) } + + /// Attaches a validated external correlation id to this projection. + /// + /// Unlike human-readable preview fields, correlation ids are never truncated: + /// changing the value would make cancellation routing ambiguous. + pub fn with_correlation_id( + mut self, + correlation_id: Option, + ) -> Result { + if let Some(value) = correlation_id { + if value.trim().is_empty() { + return Err(DomainError::Invariant( + "live-state correlation id must not be empty".to_owned(), + )); + } + if value.len() > FIELD_MAX_BYTES { + return Err(DomainError::Invariant(format!( + "live-state correlation id exceeds the max size of {FIELD_MAX_BYTES} bytes ({} bytes)", + value.len() + ))); + } + self.correlation_id = Some(value); + } + Ok(self) + } } /// Enforces the field bounds: reject above the hard byte threshold, otherwise @@ -224,6 +253,7 @@ impl LiveState { status: WorkStatus::Idle, progress: Some(STALE_AT_RESTART_MARKER.to_owned()), last_delegation: None, + correlation_id: e.correlation_id.clone(), updated_at_ms: now_ms, }) .collect() diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index befb0250..9d0524c4 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -443,9 +443,14 @@ impl McpServer { } else { &self.requester }; + let guidance = if matches!(name, "idea_project_ask_agent" | "idea_project_cancel_agent") { + "; enable this cross-project action in the requester's project MCP tool permissions" + } else { + "" + }; Err(JsonRpcError::new( error_codes::INVALID_PARAMS, - format!("MCP tool `{name}` is not permitted for requester {requester}"), + format!("MCP tool `{name}` is not permitted for requester {requester}{guidance}"), )) } diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 63484070..247b36b6 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -228,7 +228,7 @@ pub fn catalogue() -> Vec { }, ToolDef { name: "idea_project_ask_agent", - description: "Ask a cross-project contactable agent by projectId and agentId and wait for its final answer.", + description: "Ask a cross-project contactable agent by projectId and agentId and wait for its final answer. Requires explicit permission in the requester's project MCP tool settings.", input_schema: json!({ "type": "object", "properties": { @@ -243,7 +243,7 @@ pub fn catalogue() -> Vec { }, ToolDef { name: "idea_project_cancel_agent", - description: "Cancel/stop a cross-project contactable agent by projectId, agentId and correlationId.", + description: "Cancel an active cross-project request by the same projectId, agentId and correlationId used for the ask. Requires explicit permission in the requester's project MCP tool settings.", input_schema: json!({ "type": "object", "properties": { diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index aeb02b8a..ec5bd736 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -928,6 +928,60 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() { assert!(!names.contains(&"idea_template_delete")); assert!(!names.contains(&"idea_plugin_install_from_directory")); assert!(!names.contains(&"idea_plugin_reload")); + assert!(!names.contains(&"idea_project_ask_agent")); + assert!(!names.contains(&"idea_project_cancel_agent")); +} + +#[tokio::test] +async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_actionable() { + let (service, _s) = build_service(FakeContexts::new()); + let agent = AgentId::from_uuid(Uuid::from_u128(284)); + let allowed = server_with_mcp_permissions( + Arc::clone(&service), + allow_doc( + agent, + &["idea_project_ask_agent", "idea_project_cancel_agent"], + ), + ) + .for_requester(agent.to_string()); + + let listed = allowed + .handle_raw( + &serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 284, "method": "tools/list" + })) + .unwrap(), + ) + .await + .expect("tools/list reply"); + let listed_result = listed.result.unwrap(); + let names: Vec<&str> = listed_result["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"idea_project_ask_agent")); + assert!(names.contains(&"idea_project_cancel_agent")); + + let denied = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester(agent.to_string()); + let response = denied + .handle_raw(&tools_call( + 285, + "idea_project_ask_agent", + json!({ + "projectId": Uuid::from_u128(2), + "agentId": Uuid::from_u128(3), + "correlationId": "corr-denied", + "task": "work" + }), + )) + .await + .expect("denied call reply"); + let error = response.error.expect("permission error"); + assert!(error.message.contains("not permitted")); + assert!(error.message.contains("project MCP tool permissions")); } // --------------------------------------------------------------------------- diff --git a/frontend/src/features/plugins/AgentPluginCapabilities.tsx b/frontend/src/features/plugins/AgentPluginCapabilities.tsx index cdd20585..78b26816 100644 --- a/frontend/src/features/plugins/AgentPluginCapabilities.tsx +++ b/frontend/src/features/plugins/AgentPluginCapabilities.tsx @@ -68,10 +68,20 @@ export function AgentPluginCapabilities({ return (
+
+

+ Capabilities de plugins — {agentName} +

+

+ Attribuez à cet agent les skills et tools MCP fournis par les plugins activés + dans les paramètres du projet. +

+
+ {vm.error &&

{vm.error}

}
-

Skills de plugins

+
Skills de plugins
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (

Aucun skill fourni par les plugins activés pour ce projet.

) : ( @@ -80,7 +90,7 @@ export function AgentPluginCapabilities({ if (skills.length === 0) return null; return ( -
{plugin.displayName}
+
{plugin.displayName}
{skills.map((skill) => { const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`); return ( @@ -110,7 +120,7 @@ export function AgentPluginCapabilities({
-

Tools MCP de plugins

+
Tools MCP de plugins
{assignedTools.length === 0 ? (

Aucun tool MCP de plugin attribué à l’agent.

) : ( diff --git a/frontend/src/features/plugins/PluginsPanel.tsx b/frontend/src/features/plugins/PluginsPanel.tsx index 57d98f2b..ca15946d 100644 --- a/frontend/src/features/plugins/PluginsPanel.tsx +++ b/frontend/src/features/plugins/PluginsPanel.tsx @@ -38,9 +38,10 @@ interface InstallFlow { export interface PluginsPanelProps { projectId?: string; + onAssignAgents?: () => void; } -export function PluginsPanel({ projectId }: PluginsPanelProps = {}) { +export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps = {}) { const vm = usePlugins(); const pluginRuntime = usePluginRuntime(); const [pendingAction, setPendingAction] = useState(null); @@ -93,7 +94,7 @@ export function PluginsPanel({ projectId }: PluginsPanelProps = {}) { return (
{projectId ? ( - + ) : (

diff --git a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx index 41b4f58e..05efdc12 100644 --- a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx +++ b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import { createMockGateways, MockPluginGateway } from "@/adapters/mock"; @@ -63,6 +63,22 @@ describe("project plugin settings #282", () => { ]); }); + it("offers an obvious entry point to assign plugin capabilities to agents", async () => { + const gateways = gatewaysWithPlugin(); + const onAssignAgents = vi.fn(); + render( + + + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Attribuer aux agents" })); + expect(onAssignAgents).toHaveBeenCalledOnce(); + }); + it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => { const gateways = gatewaysWithPlugin(); gateways.plugin._seedProjectPluginSettings("project-1", { @@ -80,6 +96,7 @@ describe("project plugin settings #282", () => { , ); + expect(await screen.findByText("Capabilities de plugins — Dev")).toBeTruthy(); const skill = await screen.findByRole("checkbox", { name: /unity-debug/ }); fireEvent.click(skill); await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true)); diff --git a/frontend/src/features/plugins/ProjectPluginsPanel.tsx b/frontend/src/features/plugins/ProjectPluginsPanel.tsx index 1096af07..08232719 100644 --- a/frontend/src/features/plugins/ProjectPluginsPanel.tsx +++ b/frontend/src/features/plugins/ProjectPluginsPanel.tsx @@ -1,15 +1,19 @@ -import { Panel, Spinner } from "@/shared"; +import { Button, Panel, Spinner } from "@/shared"; import { useProjectPlugins } from "./useProjectPlugins"; export interface ProjectPluginsPanelProps { projectId: string; + onAssignAgents?: () => void; } function countLabel(count: number, singular: string, plural: string): string { return `${count} ${count === 1 ? singular : plural}`; } -export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) { +export function ProjectPluginsPanel({ + projectId, + onAssignAgents, +}: ProjectPluginsPanelProps) { const vm = useProjectPlugins(projectId); if (vm.loading && !vm.settings) { @@ -28,12 +32,19 @@ export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) { return (

-
-

Plugins du projet

-

- Un plugin reste installé globalement. Activez-le ici avant d’attribuer ses - capabilities aux agents de ce projet. -

+
+
+

Plugins du projet

+

+ Activez les plugins utilisés par ce projet, puis attribuez leurs skills et + tools MCP à chaque agent. +

+
+ {onAssignAgents && ( + + )}
{vm.error &&

{vm.error}

} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 68e5aa05..58abf9c8 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -910,6 +910,12 @@ export function ProjectsView() { section={settingsSection} projectId={active?.id} onSectionChange={setSettingsSection} + onAssignPluginCapabilities={() => { + setSettingsSection(null); + if (placementOf(placements, "agents") === "closed") { + setPlacement("agents", "floating"); + } + }} onClose={() => setSettingsSection(null)} /> ) : active ? ( diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx index f87e7667..c1a4c85a 100644 --- a/frontend/src/features/settings/SettingsView.tsx +++ b/frontend/src/features/settings/SettingsView.tsx @@ -50,6 +50,7 @@ interface SettingsViewProps { section: SettingsSection; projectId?: string; onSectionChange: (section: SettingsSection) => void; + onAssignPluginCapabilities?: () => void; onClose: () => void; } @@ -57,6 +58,7 @@ export function SettingsView({ section, projectId, onSectionChange, + onAssignPluginCapabilities, onClose, }: SettingsViewProps) { return ( @@ -99,7 +101,10 @@ export function SettingsView({ ) : section === "deployment" ? ( ) : section === "plugins" ? ( - + ) : ( // No `onSessionEnded`: the desktop app hosts the server and is never // itself a paired device, so it cannot revoke its own session.