fix(orchestrator,plugins): contact cross-project corrélé + découvrabilité assignation plugin par agent — #284 (backend: registre des demandes actives clé projet/agent appelant + correlationId, refus corrélation vide/dupliquée, cancel routé après correspondance exacte sans redirection vers une autre cible, appelant projeté Waiting→Done dans le live-state de son propre projet avec correlationId exposé par idea_workstate_read, gardes RAII réconcilient l'abandon du futur, tools ask/cancel WriteAction deny-by-default dont le refus indique où activer la permission; frontend: action Attribuer aux agents depuis Settings projet > Plugins ouvrant le panneau Agents flottant/ancré/détaché, section Capabilities de plugins — {agent} explicitant attribution skills et tools MCP des plugins activés; QA verte: orchestrator_service 79/79, mcp_server 40/40, domain live_state 15/15, correlated_update_is_preserved_in_the_lean_projection 1/1, frontend ProjectPluginSettings 3 tests, npm run build frontend, cargo fmt --check, git diff --check; AppImage IdeA_0.3.0_amd64.AppImage rebuildée workflow build-appimage, validation live cross-project restant après remplacement/relance manuels; réserves: registre corrélation en mémoire non persisté, surface web unsupported, catalogue dynamique tools MCP = réserve #282)
This commit is contained in:
@ -401,6 +401,68 @@ impl Drop for LiveStateTurnGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop-safe caller-side live-state cleanup for a cross-project request.
|
||||||
|
struct CrossProjectLiveStateGuard {
|
||||||
|
update: Option<Arc<UpdateLiveState>>,
|
||||||
|
requester: AgentId,
|
||||||
|
correlation_id: String,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrossProjectLiveStateGuard {
|
||||||
|
fn new(
|
||||||
|
update: Option<Arc<UpdateLiveState>>,
|
||||||
|
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
|
/// 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
|
/// 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).
|
/// 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
|
/// attach `idea_run_in_background` tasks for observability/cancellation while the
|
||||||
/// synchronous answer remains the first structured `Final`.
|
/// synchronous answer remains the first structured `Final`.
|
||||||
active_rendezvous: StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
|
active_rendezvous: StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
|
||||||
|
/// 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<HashMap<CrossProjectRequestKey, ActiveCrossProjectRequest>>,
|
||||||
/// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un
|
/// 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
|
/// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
|
||||||
/// publication (l'`ask` fonctionne quand même).
|
/// publication (l'`ask` fonctionne quand même).
|
||||||
@ -653,6 +718,19 @@ struct ActiveRendezvous {
|
|||||||
background_tasks: Vec<TaskId>,
|
background_tasks: Vec<TaskId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 {
|
impl OrchestratorService {
|
||||||
/// Builds the service from the use cases and ports it dispatches to.
|
/// Builds the service from the use cases and ports it dispatches to.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@ -683,6 +761,7 @@ impl OrchestratorService {
|
|||||||
wait_for: StdMutex::new(WaitForGraph::new()),
|
wait_for: StdMutex::new(WaitForGraph::new()),
|
||||||
active_waits: StdMutex::new(Vec::new()),
|
active_waits: StdMutex::new(Vec::new()),
|
||||||
active_rendezvous: StdMutex::new(HashMap::new()),
|
active_rendezvous: StdMutex::new(HashMap::new()),
|
||||||
|
cross_project_requests: StdMutex::new(HashMap::new()),
|
||||||
events: None,
|
events: None,
|
||||||
ask_locks: StdMutex::new(HashMap::new()),
|
ask_locks: StdMutex::new(HashMap::new()),
|
||||||
mcp_runtime_provider: None,
|
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<CrossProjectRequestGuard<'_>, 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`.
|
/// 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
|
/// 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,
|
requester_agent_id: AgentId,
|
||||||
correlation_id: String,
|
correlation_id: String,
|
||||||
) -> Result<OrchestratorOutcome, AppError> {
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
|
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 project = self.load_project_for_cross_tool(target_project_id).await?;
|
||||||
let target = self
|
let target = self
|
||||||
.find_agent_by_id(&project, target_agent_id)
|
.find_agent_by_id(&project, target_agent_id)
|
||||||
@ -1470,11 +1646,46 @@ impl OrchestratorService {
|
|||||||
"agent {target_agent_id} is not contactable cross-project"
|
"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!(
|
crate::diag!(
|
||||||
"[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
|
"[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))
|
self.mark_cross_project_requester_best_effort(
|
||||||
.await
|
&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.
|
/// Cancels/stops one contactable agent in another project by stable ids.
|
||||||
@ -1486,22 +1697,47 @@ impl OrchestratorService {
|
|||||||
requester_agent_id: AgentId,
|
requester_agent_id: AgentId,
|
||||||
correlation_id: String,
|
correlation_id: String,
|
||||||
) -> Result<OrchestratorOutcome, AppError> {
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
let project = self.load_project_for_cross_tool(target_project_id).await?;
|
let key = CrossProjectRequestKey {
|
||||||
let target = self
|
requester_project_id,
|
||||||
.find_agent_by_id(&project, target_agent_id)
|
requester_agent_id,
|
||||||
.await?
|
correlation_id: correlation_id.clone(),
|
||||||
.ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?;
|
};
|
||||||
if !target.contactable {
|
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!(
|
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!(
|
crate::diag!(
|
||||||
"[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
|
"[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}"
|
||||||
);
|
);
|
||||||
let _ = requester_agent_id;
|
let mut outcome = self
|
||||||
self.stop_agent_by_id(&project, target.id, target.name, None)
|
.stop_agent_by_id(&project, target.id, target.name, None)
|
||||||
.await
|
.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(
|
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
|
/// 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).
|
/// 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(
|
async fn read_workstate(
|
||||||
&self,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
@ -1756,6 +1992,9 @@ impl OrchestratorService {
|
|||||||
if let Some(last) = entry.last_delegation {
|
if let Some(last) = entry.last_delegation {
|
||||||
row["lastDelegation"] = serde_json::json!(last);
|
row["lastDelegation"] = serde_json::json!(last);
|
||||||
}
|
}
|
||||||
|
if let Some(correlation_id) = entry.correlation_id {
|
||||||
|
row["correlationId"] = serde_json::json!(correlation_id);
|
||||||
|
}
|
||||||
Some(row)
|
Some(row)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -3440,6 +3679,24 @@ impl Drop for ActiveRendezvousGuard<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CrossProjectRequestGuard<'a> {
|
||||||
|
active: &'a StdMutex<HashMap<CrossProjectRequestKey, ActiveCrossProjectRequest>>,
|
||||||
|
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,
|
/// Normalises a profile reference for tolerant matching: lowercased, with spaces,
|
||||||
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
|
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
|
||||||
/// → comparable forms; `claude` ⊂ ... handled by the command match above).
|
/// → comparable forms; `claude` ⊂ ... handled by the command match above).
|
||||||
|
|||||||
@ -85,6 +85,24 @@ impl UpdateLiveState {
|
|||||||
/// [`AppError::Invalid`] if a text field exceeds the domain hard threshold;
|
/// [`AppError::Invalid`] if a text field exceeds the domain hard threshold;
|
||||||
/// [`AppError::Store`] on a persistence failure.
|
/// [`AppError::Store`] on a persistence failure.
|
||||||
pub async fn execute(&self, input: UpdateLiveStateInput) -> Result<(), AppError> {
|
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<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
|
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
|
||||||
let entry = LiveEntry::new(
|
let entry = LiveEntry::new(
|
||||||
input.agent_id,
|
input.agent_id,
|
||||||
@ -95,6 +113,7 @@ impl UpdateLiveState {
|
|||||||
input.last_delegation,
|
input.last_delegation,
|
||||||
now_ms,
|
now_ms,
|
||||||
)
|
)
|
||||||
|
.and_then(|entry| entry.with_correlation_id(correlation_id))
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.store.upsert(entry).await?;
|
self.store.upsert(entry).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -117,6 +136,9 @@ pub struct LeanLiveEntry {
|
|||||||
pub ticket: Option<TicketId>,
|
pub ticket: Option<TicketId>,
|
||||||
/// The ticket of the most recent delegation it issued, if any.
|
/// The ticket of the most recent delegation it issued, if any.
|
||||||
pub last_delegation: Option<TicketId>,
|
pub last_delegation: Option<TicketId>,
|
||||||
|
/// External correlation for an active/completed cross-project request.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub correlation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<LiveEntry> for LeanLiveEntry {
|
impl From<LiveEntry> for LeanLiveEntry {
|
||||||
@ -127,6 +149,7 @@ impl From<LiveEntry> for LeanLiveEntry {
|
|||||||
status: e.status,
|
status: e.status,
|
||||||
ticket: e.ticket,
|
ticket: e.ticket,
|
||||||
last_delegation: e.last_delegation,
|
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]
|
#[tokio::test]
|
||||||
async fn update_applies_domain_bounds_truncation() {
|
async fn update_applies_domain_bounds_truncation() {
|
||||||
let store = Arc::new(InMemoryLiveStateStore::default());
|
let store = Arc::new(InMemoryLiveStateStore::default());
|
||||||
|
|||||||
@ -902,7 +902,10 @@ async fn cross_project_list_agents_filters_to_contactable_agents() {
|
|||||||
let target_project = other_project();
|
let target_project = other_project();
|
||||||
let svc = fx
|
let svc = fx
|
||||||
.service
|
.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
|
let out = svc
|
||||||
.list_project_agents_for_mcp(target_project.id)
|
.list_project_agents_for_mcp(target_project.id)
|
||||||
@ -919,11 +922,17 @@ async fn cross_project_list_agents_filters_to_contactable_agents() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cross_project_ask_rejects_non_contactable_agent_before_launch() {
|
async fn cross_project_ask_rejects_non_contactable_agent_before_launch() {
|
||||||
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
|
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 target_project = other_project();
|
||||||
let svc = fx
|
let svc = fx
|
||||||
.service
|
.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
|
let err = svc
|
||||||
.ask_project_agent_for_mcp(
|
.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);
|
scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true);
|
||||||
let contexts = FakeContexts::with_agent(&first, "# first");
|
let contexts = FakeContexts::with_agent(&first, "# first");
|
||||||
contexts.add_agent(&second, "# second");
|
contexts.add_agent(&second, "# second");
|
||||||
|
contexts.add_agent(
|
||||||
|
&scratch_agent(aid(99), "Requester", "agents/requester.md"),
|
||||||
|
"# requester",
|
||||||
|
);
|
||||||
let fx = ask_fixture(contexts);
|
let fx = ask_fixture(contexts);
|
||||||
let target_project = other_project();
|
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);
|
scratch_agent(aid(2), "Architect", "agents/architect-second.md").with_contactable(true);
|
||||||
let contexts = FakeContexts::with_agent(&first, "# first");
|
let contexts = FakeContexts::with_agent(&first, "# first");
|
||||||
contexts.add_agent(&second, "# second");
|
contexts.add_agent(&second, "# second");
|
||||||
|
contexts.add_agent(
|
||||||
|
&scratch_agent(aid(99), "Requester", "agents/requester.md"),
|
||||||
|
"# requester",
|
||||||
|
);
|
||||||
let fx = ask_fixture(contexts);
|
let fx = ask_fixture(contexts);
|
||||||
let target_project = other_project();
|
let target_project = other_project();
|
||||||
let first_cancelled = Arc::new(Mutex::new(0_u32));
|
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
|
fx.structured
|
||||||
.insert_in_project(target_project.id, second_session, second.id, nid(9102));
|
.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,
|
TEST_GUARD,
|
||||||
fx.service.cancel_project_agent_for_mcp(
|
fx.service.cancel_project_agent_for_mcp(
|
||||||
target_project.id,
|
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 must not hang")
|
||||||
.expect("cross-project cancel ok");
|
.expect("cross-project cancel ok");
|
||||||
|
|
||||||
|
assert!(cancelled.detail.contains("corr-homonym-cancel"));
|
||||||
assert_eq!(*first_cancelled.lock().unwrap(), 0);
|
assert_eq!(*first_cancelled.lock().unwrap(), 0);
|
||||||
assert_eq!(*second_cancelled.lock().unwrap(), 1);
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@ -106,6 +106,9 @@ pub struct LiveEntry {
|
|||||||
pub progress: Option<String>,
|
pub progress: Option<String>,
|
||||||
/// The ticket of the most recent delegation this agent issued, if any.
|
/// The ticket of the most recent delegation this agent issued, if any.
|
||||||
pub last_delegation: Option<TicketId>,
|
pub last_delegation: Option<TicketId>,
|
||||||
|
/// External correlation carried by an active cross-project request, if any.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub correlation_id: Option<String>,
|
||||||
/// Wall-clock update time in epoch milliseconds (drives `prune` ordering).
|
/// Wall-clock update time in epoch milliseconds (drives `prune` ordering).
|
||||||
pub updated_at_ms: u64,
|
pub updated_at_ms: u64,
|
||||||
}
|
}
|
||||||
@ -138,9 +141,35 @@ impl LiveEntry {
|
|||||||
status,
|
status,
|
||||||
progress,
|
progress,
|
||||||
last_delegation,
|
last_delegation,
|
||||||
|
correlation_id: None,
|
||||||
updated_at_ms,
|
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<String>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
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
|
/// Enforces the field bounds: reject above the hard byte threshold, otherwise
|
||||||
@ -224,6 +253,7 @@ impl LiveState {
|
|||||||
status: WorkStatus::Idle,
|
status: WorkStatus::Idle,
|
||||||
progress: Some(STALE_AT_RESTART_MARKER.to_owned()),
|
progress: Some(STALE_AT_RESTART_MARKER.to_owned()),
|
||||||
last_delegation: None,
|
last_delegation: None,
|
||||||
|
correlation_id: e.correlation_id.clone(),
|
||||||
updated_at_ms: now_ms,
|
updated_at_ms: now_ms,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@ -443,9 +443,14 @@ impl McpServer {
|
|||||||
} else {
|
} else {
|
||||||
&self.requester
|
&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(
|
Err(JsonRpcError::new(
|
||||||
error_codes::INVALID_PARAMS,
|
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}"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -228,7 +228,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
},
|
},
|
||||||
ToolDef {
|
ToolDef {
|
||||||
name: "idea_project_ask_agent",
|
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!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -243,7 +243,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
},
|
},
|
||||||
ToolDef {
|
ToolDef {
|
||||||
name: "idea_project_cancel_agent",
|
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!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -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_template_delete"));
|
||||||
assert!(!names.contains(&"idea_plugin_install_from_directory"));
|
assert!(!names.contains(&"idea_plugin_install_from_directory"));
|
||||||
assert!(!names.contains(&"idea_plugin_reload"));
|
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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -68,10 +68,20 @@ export function AgentPluginCapabilities({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-content">
|
||||||
|
Capabilities de plugins — {agentName}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
Attribuez à cet agent les skills et tools MCP fournis par les plugins activés
|
||||||
|
dans les paramètres du projet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>}
|
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>}
|
||||||
|
|
||||||
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
|
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
|
||||||
<h4 className="text-sm font-semibold text-content">Skills de plugins</h4>
|
<h5 className="text-sm font-semibold text-content">Skills de plugins</h5>
|
||||||
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
|
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
|
||||||
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
|
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
|
||||||
) : (
|
) : (
|
||||||
@ -80,7 +90,7 @@ export function AgentPluginCapabilities({
|
|||||||
if (skills.length === 0) return null;
|
if (skills.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Panel key={plugin.id} className="flex flex-col gap-2">
|
<Panel key={plugin.id} className="flex flex-col gap-2">
|
||||||
<h5 className="text-xs font-semibold text-content">{plugin.displayName}</h5>
|
<h6 className="text-xs font-semibold text-content">{plugin.displayName}</h6>
|
||||||
{skills.map((skill) => {
|
{skills.map((skill) => {
|
||||||
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
|
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
|
||||||
return (
|
return (
|
||||||
@ -110,7 +120,7 @@ export function AgentPluginCapabilities({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2">
|
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2">
|
||||||
<h4 className="text-sm font-semibold text-content">Tools MCP de plugins</h4>
|
<h5 className="text-sm font-semibold text-content">Tools MCP de plugins</h5>
|
||||||
{assignedTools.length === 0 ? (
|
{assignedTools.length === 0 ? (
|
||||||
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p>
|
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -38,9 +38,10 @@ interface InstallFlow {
|
|||||||
|
|
||||||
export interface PluginsPanelProps {
|
export interface PluginsPanelProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
onAssignAgents?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginsPanel({ projectId }: PluginsPanelProps = {}) {
|
export function PluginsPanel({ projectId, onAssignAgents }: PluginsPanelProps = {}) {
|
||||||
const vm = usePlugins();
|
const vm = usePlugins();
|
||||||
const pluginRuntime = usePluginRuntime();
|
const pluginRuntime = usePluginRuntime();
|
||||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||||
@ -93,7 +94,7 @@ export function PluginsPanel({ projectId }: PluginsPanelProps = {}) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{projectId ? (
|
{projectId ? (
|
||||||
<ProjectPluginsPanel projectId={projectId} />
|
<ProjectPluginsPanel projectId={projectId} onAssignAgents={onAssignAgents} />
|
||||||
) : (
|
) : (
|
||||||
<Panel>
|
<Panel>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
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 { DIProvider } from "@/app/di";
|
||||||
import { createMockGateways, MockPluginGateway } from "@/adapters/mock";
|
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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<ProjectPluginsPanel
|
||||||
|
projectId="project-1"
|
||||||
|
onAssignAgents={onAssignAgents}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
|
||||||
const gateways = gatewaysWithPlugin();
|
const gateways = gatewaysWithPlugin();
|
||||||
gateways.plugin._seedProjectPluginSettings("project-1", {
|
gateways.plugin._seedProjectPluginSettings("project-1", {
|
||||||
@ -80,6 +96,7 @@ describe("project plugin settings #282", () => {
|
|||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Capabilities de plugins — Dev")).toBeTruthy();
|
||||||
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
|
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
|
||||||
fireEvent.click(skill);
|
fireEvent.click(skill);
|
||||||
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
|
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import { Panel, Spinner } from "@/shared";
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
import { useProjectPlugins } from "./useProjectPlugins";
|
import { useProjectPlugins } from "./useProjectPlugins";
|
||||||
|
|
||||||
export interface ProjectPluginsPanelProps {
|
export interface ProjectPluginsPanelProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
onAssignAgents?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countLabel(count: number, singular: string, plural: string): string {
|
function countLabel(count: number, singular: string, plural: string): string {
|
||||||
return `${count} ${count === 1 ? singular : plural}`;
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) {
|
export function ProjectPluginsPanel({
|
||||||
|
projectId,
|
||||||
|
onAssignAgents,
|
||||||
|
}: ProjectPluginsPanelProps) {
|
||||||
const vm = useProjectPlugins(projectId);
|
const vm = useProjectPlugins(projectId);
|
||||||
|
|
||||||
if (vm.loading && !vm.settings) {
|
if (vm.loading && !vm.settings) {
|
||||||
@ -28,12 +32,19 @@ export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="plugins du projet" className="flex flex-col gap-3">
|
<section aria-label="plugins du projet" className="flex flex-col gap-3">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
<div>
|
||||||
<p className="mt-1 text-xs text-muted">
|
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
||||||
Un plugin reste installé globalement. Activez-le ici avant d’attribuer ses
|
<p className="mt-1 text-xs text-muted">
|
||||||
capabilities aux agents de ce projet.
|
Activez les plugins utilisés par ce projet, puis attribuez leurs skills et
|
||||||
</p>
|
tools MCP à chaque agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onAssignAgents && (
|
||||||
|
<Button size="sm" variant="primary" onClick={onAssignAgents}>
|
||||||
|
Attribuer aux agents
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
|
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
|
||||||
|
|||||||
@ -910,6 +910,12 @@ export function ProjectsView() {
|
|||||||
section={settingsSection}
|
section={settingsSection}
|
||||||
projectId={active?.id}
|
projectId={active?.id}
|
||||||
onSectionChange={setSettingsSection}
|
onSectionChange={setSettingsSection}
|
||||||
|
onAssignPluginCapabilities={() => {
|
||||||
|
setSettingsSection(null);
|
||||||
|
if (placementOf(placements, "agents") === "closed") {
|
||||||
|
setPlacement("agents", "floating");
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClose={() => setSettingsSection(null)}
|
onClose={() => setSettingsSection(null)}
|
||||||
/>
|
/>
|
||||||
) : active ? (
|
) : active ? (
|
||||||
|
|||||||
@ -50,6 +50,7 @@ interface SettingsViewProps {
|
|||||||
section: SettingsSection;
|
section: SettingsSection;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
onSectionChange: (section: SettingsSection) => void;
|
onSectionChange: (section: SettingsSection) => void;
|
||||||
|
onAssignPluginCapabilities?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +58,7 @@ export function SettingsView({
|
|||||||
section,
|
section,
|
||||||
projectId,
|
projectId,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
|
onAssignPluginCapabilities,
|
||||||
onClose,
|
onClose,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
return (
|
return (
|
||||||
@ -99,7 +101,10 @@ export function SettingsView({
|
|||||||
) : section === "deployment" ? (
|
) : section === "deployment" ? (
|
||||||
<DeploymentSettings />
|
<DeploymentSettings />
|
||||||
) : section === "plugins" ? (
|
) : section === "plugins" ? (
|
||||||
<PluginsPanel projectId={projectId} />
|
<PluginsPanel
|
||||||
|
projectId={projectId}
|
||||||
|
onAssignAgents={onAssignPluginCapabilities}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
// No `onSessionEnded`: the desktop app hosts the server and is never
|
// No `onSessionEnded`: the desktop app hosts the server and is never
|
||||||
// itself a paired device, so it cannot revoke its own session.
|
// itself a paired device, so it cannot revoke its own session.
|
||||||
|
|||||||
Reference in New Issue
Block a user