feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills → capacités agent découvrables), #122 (override permissions par défaut), #131 (effort par agent/presets) et #132 (outil MCP d'édition du contexte projet) sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs, backend/dto.rs), inséparable sans staging interactif (indisponible ici). Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée. NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -35,8 +35,8 @@ use domain::ports::{
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
|
||||
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
@ -50,7 +50,7 @@ use application::{
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
||||
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
||||
UpdateAgentContext, UpdateAgentContextInput,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
|
||||
struct FakeStructuredFactory {
|
||||
trace: Trace,
|
||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||
next_session: SessionId,
|
||||
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
|
||||
Self {
|
||||
trace,
|
||||
starts: Arc::new(Mutex::new(Vec::new())),
|
||||
efforts: Arc::new(Mutex::new(Vec::new())),
|
||||
envs: Arc::new(Mutex::new(Vec::new())),
|
||||
policies: Arc::new(Mutex::new(Vec::new())),
|
||||
next_session,
|
||||
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
|
||||
self.starts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn efforts(&self) -> Vec<Option<String>> {
|
||||
self.efforts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
||||
self.envs.lock().unwrap().clone()
|
||||
}
|
||||
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
||||
.unwrap()
|
||||
.push("structured.start".to_owned());
|
||||
self.starts.lock().unwrap().push(profile.id);
|
||||
self.efforts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(profile.model_reasoning_effort.clone());
|
||||
self.envs.lock().unwrap().push(_env.to_vec());
|
||||
self.policies
|
||||
.lock()
|
||||
@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
|
||||
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 list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a2.id));
|
||||
assert_eq!(entries[0].is_orchestrator, false);
|
||||
assert_eq!(entries[1].is_orchestrator, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||
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 list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a1.id));
|
||||
assert_eq!(entries[0].is_orchestrator, true);
|
||||
assert_eq!(entries[1].is_orchestrator, false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_update_context_roundtrips() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: Some(EffortSelection::Preset("high".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.agent.effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
contexts.manifest().entries[0].effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_none_clears_existing_override() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent.effort, None);
|
||||
assert_eq!(contexts.manifest().entries[0].effort, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_not_found_for_unknown_agent_id() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts));
|
||||
|
||||
let err = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: aid(404),
|
||||
effort: Some(EffortSelection::Preset("medium".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall(
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
|
||||
}
|
||||
|
||||
fn launch_fixture_with_profile_agent_and_recall(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Preset("high".to_owned())));
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
|
||||
launch_fixture_with_profile_agent_and_recall(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
FakeRecall::default(),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
|
||||
let profile = profile(
|
||||
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
|
||||
}
|
||||
|
||||
fn launch_with_projection_agent_and_env(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
|
||||
let profile = codex_profile()
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
assert!(
|
||||
args.windows(2).any(|w| w
|
||||
== [
|
||||
"-c".to_owned(),
|
||||
"model_reasoning_effort=\"x-deep\"".to_owned()
|
||||
]),
|
||||
"PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
|
||||
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
|
||||
|
||||
Reference in New Issue
Block a user