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:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -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

View File

@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId};
use domain::ports::{PermissionStore, StoreError};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{PermissionSet, Posture, ProjectPermissions};
use domain::{
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
ProjectPermissions,
};
#[derive(Default)]
struct FakePermissionStore {
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
.unwrap();
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
assert_eq!(out.shadowed, PermissionShadowReport::default());
}
#[tokio::test]
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
let agent = AgentId::new_random();
let store = Arc::new(FakePermissionStore {
doc: Mutex::new(ProjectPermissions::new(
Some(PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
)),
vec![domain::AgentPermissionOverride::new(
agent,
PermissionSet::new(
vec![PermissionRule::bash(Effect::Allow, vec![])],
Posture::Ask,
),
)],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentPermissions::new(store);
let out = use_case
.execute(ResolveAgentPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert!(out.shadowed.execute_bash);
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
}
#[tokio::test]
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
let agent = AgentId::new_random();
let store = Arc::new(FakePermissionStore {
doc: Mutex::new(ProjectPermissions::new(
Some(PermissionSet::new(
vec![PermissionRule::file(
Capability::Read,
Effect::Deny,
domain::PathScope::new(["**".to_owned()]).unwrap(),
)
.unwrap()],
Posture::Deny,
)),
vec![],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentPermissions::new(store);
let out = use_case
.execute(ResolveAgentPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert_eq!(out.shadowed, PermissionShadowReport::default());
}

View File

@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
};
use domain::skill::{Skill, SkillScope};
use domain::skill::{Skill, SkillKind, SkillScope};
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
use uuid::Uuid;
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
.execute(CreateSkillInput {
name: "refactor".to_owned(),
description: Some("Refactors code".to_owned()),
kind: SkillKind::Reference,
content: "# body".to_owned(),
scope: SkillScope::Project,
project_root: root(),
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
.unwrap();
assert_eq!(out.skill.scope, SkillScope::Project);
assert_eq!(out.skill.kind, SkillKind::Reference);
// The one-line affordance description flows through the use case onto the skill.
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
assert_eq!(
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
.execute(CreateSkillInput {
name: "k".to_owned(),
description: None,
kind: SkillKind::Workflow,
content: String::new(),
scope: SkillScope::Global,
project_root: root(),