feat(agents): role orchestrateur du contexte global expose et configurable par agent — isOrchestrator distinct des permissions MCP, DTO update_project_orchestrator (terminologie wire legacy conservee), refus idea_context_update explicite avec writer effectif + orientation idea_context_propose, surface AgentsPanel + adaptateurs frontend — #276 (QA verte: cargo fmt/check, tests cibles application/app-tauri/infrastructure/backend, npm typecheck, 69/69 tests frontend agents/adapters)

This commit is contained in:
2026-09-07 22:49:12 +02:00
parent ad2c6b4354
commit cb5196fd69
24 changed files with 663 additions and 37 deletions

View File

@ -59,6 +59,7 @@ use application::{
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput,
};
// ---------------------------------------------------------------------------
@ -1069,6 +1070,63 @@ async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated()
assert_eq!(entries[1].is_orchestrator, false);
}
#[tokio::test]
async fn update_project_orchestrator_persists_single_writer_and_publishes_change() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
contexts
.0
.lock()
.unwrap()
.manifest
.entries
.push(ManifestEntry::from_agent(&a2));
let events = SpyBus::default();
let update =
UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
let out = update
.execute(UpdateProjectOrchestratorInput {
project: project(),
agent_id: a2.id,
})
.await
.unwrap();
assert_eq!(out.orchestrator, a2.id);
assert_eq!(contexts.manifest().orchestrator, Some(a2.id));
assert_eq!(contexts.manifest().effective_orchestrator(), Some(a2.id));
assert_eq!(
events.events(),
vec![DomainEvent::OrchestratorChanged {
project_id: project().id,
orchestrator: Some(a2.id),
}]
);
}
#[tokio::test]
async fn update_project_orchestrator_rejects_unknown_agent_without_mutation_or_event() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
let events = SpyBus::default();
let update =
UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
let err = update
.execute(UpdateProjectOrchestratorInput {
project: project(),
agent_id: aid(404),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND");
assert_eq!(contexts.manifest().orchestrator, None);
assert!(events.events().is_empty());
}
#[tokio::test]
async fn read_then_update_context_roundtrips() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
@ -1125,6 +1183,30 @@ async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
);
}
#[tokio::test]
async fn agent_mutation_preserves_explicit_project_orchestrator() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
{
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
inner.manifest.designate(a2.id).unwrap();
}
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: a1.id,
effort: Some(EffortSelection::Preset("high".to_owned())),
})
.await
.unwrap();
assert_eq!(contexts.manifest().orchestrator, Some(a2.id));
}
#[tokio::test]
async fn update_agent_effort_none_clears_existing_override() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
@ -1236,6 +1318,35 @@ async fn delete_removes_entry_then_unknown_is_not_found() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn deleting_writer_clears_explicit_designation_and_falls_back_to_oldest_remaining() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
{
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
inner.manifest.designate(a2.id).unwrap();
}
let events = SpyBus::default();
let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
delete
.execute(DeleteAgentInput {
project: project(),
agent_id: a2.id,
})
.await
.unwrap();
assert_eq!(contexts.manifest().orchestrator, None);
assert_eq!(contexts.manifest().effective_orchestrator(), Some(a1.id));
assert!(events.events().contains(&DomainEvent::OrchestratorChanged {
project_id: project().id,
orchestrator: None,
}));
}
#[tokio::test]
async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));