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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user