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:
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user