feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -18,7 +18,9 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
@ -57,7 +59,12 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
None,
|
||||
)
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude),
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
reference_id("codex"),
|
||||
"OpenAI Codex CLI",
|
||||
@ -70,7 +77,12 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
None,
|
||||
)
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex),
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
@ -119,3 +131,50 @@ pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
|
||||
.filter(AgentProfile::is_selectable)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mcp_tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(slug: &str) -> AgentProfile {
|
||||
let id = reference_id(slug);
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_expose_mcp_capability() {
|
||||
for slug in ["claude", "codex"] {
|
||||
let p = profile(slug);
|
||||
assert!(
|
||||
p.mcp.is_some(),
|
||||
"structured profile `{slug}` must carry an MCP capability"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_mcp_use_config_file_mcp_json() {
|
||||
for slug in ["claude", "codex"] {
|
||||
let mcp = profile(slug).mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
|
||||
"profile `{slug}` should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_and_aider_have_no_mcp_capability() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
assert!(
|
||||
profile(slug).mcp.is_none(),
|
||||
"non-structured profile `{slug}` must NOT carry MCP (file fallback)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -997,10 +997,19 @@ impl LaunchAgent {
|
||||
&project_context,
|
||||
&skills,
|
||||
&memory,
|
||||
profile.mcp.is_some(),
|
||||
&mut spec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||||
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
|
||||
// run dir isolé que le convention file et le seed de permissions. `None`
|
||||
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
|
||||
self.apply_mcp_config(&profile, &run_dir, &mut spec).await;
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
||||
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
|
||||
@ -1226,6 +1235,7 @@ impl LaunchAgent {
|
||||
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
|
||||
/// or attaching the on-disk context path to an environment variable. `Args` is
|
||||
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn apply_injection(
|
||||
&self,
|
||||
project: &Project,
|
||||
@ -1234,6 +1244,7 @@ impl LaunchAgent {
|
||||
project_context: &str,
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
mcp_enabled: bool,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
match spec.context_plan.clone() {
|
||||
@ -1251,6 +1262,7 @@ impl LaunchAgent {
|
||||
content.as_str(),
|
||||
skills,
|
||||
memory,
|
||||
mcp_enabled,
|
||||
);
|
||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||
self.fs.write(&path, document.as_bytes()).await?;
|
||||
@ -1267,6 +1279,92 @@ impl LaunchAgent {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Materialises the IdeA MCP server config for **this** CLI when the profile
|
||||
/// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the
|
||||
/// convention file (`apply_injection`) and **before** the spawn / `factory.start`,
|
||||
/// in the **same** isolated run dir as the convention file and the permission seed.
|
||||
///
|
||||
/// Dispatch by [`McpConfigStrategy`]:
|
||||
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
|
||||
/// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly
|
||||
/// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited)
|
||||
/// is left untouched, and any write/exists failure is swallowed so it **never**
|
||||
/// fails the launch.
|
||||
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
|
||||
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
|
||||
///
|
||||
/// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero
|
||||
/// regression).
|
||||
///
|
||||
/// Note (M1): the embedded server declaration (command + transport) is a coherent
|
||||
/// **placeholder** — the real IdeA MCP server and its exact launch command land in
|
||||
/// M2/M3. The strategy plumbing here is stable; only the declaration content will
|
||||
/// be finalised then.
|
||||
async fn apply_mcp_config(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
spec: &mut SpawnSpec,
|
||||
) {
|
||||
let Some(mcp) = &profile.mcp else {
|
||||
return;
|
||||
};
|
||||
match &mcp.config {
|
||||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||||
// Non-clobbering, best-effort — mirrors `seed_cli_permissions`.
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let declaration = mcp_server_declaration(mcp.transport);
|
||||
// Best-effort: a write failure must not fail the launch.
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
// An exists() probe failure is swallowed too (best-effort).
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
|
||||
// config path is the run dir itself (the CLI's cwd), where the server
|
||||
// declaration / connection is anchored.
|
||||
spec.args.push(flag.clone());
|
||||
spec.args.push(run_dir.as_str().to_owned());
|
||||
}
|
||||
domain::profile::McpConfigStrategy::Env { var } => {
|
||||
spec.env.push((var.clone(), run_dir.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
|
||||
/// config (cadrage v3, Décision 3). Coherent **placeholder** for M1: it describes a
|
||||
/// stdio/socket server launched by the `idea` binary — the exact command and the
|
||||
/// real server land in **M2/M3**. Kept pure (no I/O) so it is unit-testable.
|
||||
#[must_use]
|
||||
fn mcp_server_declaration(transport: domain::profile::McpTransport) -> String {
|
||||
// Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport
|
||||
// is surfaced so a socket-based CLI can be wired in M2 without changing M1's seam.
|
||||
let transport_label = match transport {
|
||||
domain::profile::McpTransport::Stdio => "stdio",
|
||||
domain::profile::McpTransport::Socket => "socket",
|
||||
};
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"idea": {{
|
||||
"command": "idea",
|
||||
"args": [
|
||||
"mcp-server"
|
||||
],
|
||||
"transport": "{transport_label}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||
@ -1402,6 +1500,13 @@ fn json_escape(s: &str) -> String {
|
||||
/// entirely, so an agent with no memory gets exactly the previous document.
|
||||
///
|
||||
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
|
||||
///
|
||||
/// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision
|
||||
/// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the
|
||||
/// native `idea_*` tools, so the prose points to those instead of the file
|
||||
/// protocol — while keeping the **same** ban on the provider's native subagents.
|
||||
/// When `false` (`mcp == None`) the prose is the **current `.ideai/requests`
|
||||
/// wording, unchanged** (zero regression).
|
||||
#[must_use]
|
||||
pub(crate) fn compose_convention_file(
|
||||
project_root: &str,
|
||||
@ -1409,6 +1514,7 @@ pub(crate) fn compose_convention_file(
|
||||
agent_md: &str,
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
mcp_enabled: bool,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Project root\n\n");
|
||||
@ -1420,12 +1526,26 @@ pub(crate) fn compose_convention_file(
|
||||
);
|
||||
out.push_str("---\n\n");
|
||||
out.push_str("# Orchestration IdeA\n\n");
|
||||
out.push_str(
|
||||
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
||||
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
|
||||
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
|
||||
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
||||
);
|
||||
if mcp_enabled {
|
||||
// Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est
|
||||
// exposée comme outils typés `idea_*`. L'interdiction des subagents natifs
|
||||
// du fournisseur est **conservée** ; seule la voie de délégation change.
|
||||
out.push_str(
|
||||
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
||||
natifs du fournisseur IA. Utilise les outils IdeA natifs : \
|
||||
`idea_ask_agent` (déléguer une tâche et recevoir la réponse), \
|
||||
`idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \
|
||||
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \
|
||||
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
||||
);
|
||||
} else {
|
||||
out.push_str(
|
||||
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
||||
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
|
||||
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
|
||||
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
||||
);
|
||||
}
|
||||
out.push_str("---\n\n");
|
||||
|
||||
if !project_context.trim().is_empty() {
|
||||
@ -1533,8 +1653,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_carries_root_then_persona() {
|
||||
let doc =
|
||||
compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]);
|
||||
let doc = compose_convention_file(
|
||||
"/abs/project/root",
|
||||
"",
|
||||
"# Persona\n\nDo things.",
|
||||
&[],
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
// Absolute project root present.
|
||||
assert!(doc.contains("/abs/project/root"));
|
||||
@ -1557,6 +1683,7 @@ mod tests {
|
||||
"# Persona\n\nDo X.",
|
||||
&[],
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(doc.contains("# Contexte projet"));
|
||||
@ -1589,6 +1716,7 @@ mod tests {
|
||||
s(2, "review", "REVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
// Both skill bodies present, after the persona.
|
||||
@ -1619,7 +1747,8 @@ mod tests {
|
||||
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
||||
// An empty `memory` must yield exactly the previous document: no section,
|
||||
// byte-for-byte identical to the no-skills/no-memory composition.
|
||||
let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]);
|
||||
let with_empty =
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false);
|
||||
assert!(
|
||||
!with_empty.contains("# Mémoire projet"),
|
||||
"no memory ⇒ no memory section"
|
||||
@ -1628,7 +1757,7 @@ mod tests {
|
||||
// 4-arg call; this pins the omission contract explicitly).
|
||||
assert_eq!(
|
||||
with_empty,
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[])
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], false)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1648,6 +1777,7 @@ mod tests {
|
||||
MemoryType::Reference,
|
||||
),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
// Section present, after the persona.
|
||||
@ -1684,6 +1814,7 @@ mod tests {
|
||||
"# Persona",
|
||||
std::slice::from_ref(&skill),
|
||||
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
||||
false,
|
||||
);
|
||||
|
||||
// Both sections present.
|
||||
@ -1698,6 +1829,52 @@ mod tests {
|
||||
assert!(skills_at < memory_at, "skills come before memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
|
||||
// mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
|
||||
// the ban on the provider's native subagents (cadrage v3, Décision 3).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true);
|
||||
|
||||
// Native IdeA orchestration tools surfaced.
|
||||
assert!(
|
||||
doc.contains("idea_ask_agent"),
|
||||
"MCP prose must mention idea_ask_agent"
|
||||
);
|
||||
assert!(doc.contains("idea_launch_agent"));
|
||||
assert!(doc.contains("idea_list_agents"));
|
||||
// Native-subagent ban preserved.
|
||||
assert!(
|
||||
doc.contains("n'utilise jamais les subagents"),
|
||||
"MCP prose must keep the native-subagent ban"
|
||||
);
|
||||
// It does NOT fall back to the file protocol wording.
|
||||
assert!(
|
||||
!doc.contains(".ideai/requests"),
|
||||
"MCP prose must not point to the file protocol"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
|
||||
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
|
||||
// and crucially WITHOUT the `idea_*` tools (zero regression).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], false);
|
||||
|
||||
assert!(
|
||||
doc.contains(".ideai/requests"),
|
||||
"non-MCP prose must point to the file protocol"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("n'utilise jamais les subagents"),
|
||||
"non-MCP prose keeps the native-subagent ban"
|
||||
);
|
||||
// No native MCP tools leaked into the file-protocol prose.
|
||||
assert!(
|
||||
!doc.contains("idea_ask_agent"),
|
||||
"non-MCP prose must not mention the idea_* tools"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
||||
let json = claude_settings_seed("/home/me/proj");
|
||||
|
||||
@ -147,6 +147,7 @@ impl OrchestratorService {
|
||||
OrchestratorCommand::AskAgent { target, task } => {
|
||||
self.ask_agent(project, target, task).await
|
||||
}
|
||||
OrchestratorCommand::ListAgents => self.list_agents(project).await,
|
||||
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
|
||||
OrchestratorCommand::UpdateAgentContext { name, context } => {
|
||||
self.update_agent_context(project, name, context).await
|
||||
@ -355,6 +356,35 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
/// `list_agents`: discovery — return the project's agents exactly as the UI
|
||||
/// reads them from the manifest, via the **same** [`ListAgents`] use case.
|
||||
///
|
||||
/// The list is serialised as a JSON array into [`OrchestratorOutcome::reply`]
|
||||
/// (the existing inline-payload channel, also used by `ask`), with a one-line
|
||||
/// count in [`OrchestratorOutcome::detail`]. Each element carries the agent's
|
||||
/// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized` and
|
||||
/// `skills` (camelCase, the [`domain::Agent`] serde shape).
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates [`AppError`] from the use case (manifest load / invariant) or a
|
||||
/// serialisation failure ([`AppError::Invalid`]).
|
||||
async fn list_agents(&self, project: &Project) -> Result<OrchestratorOutcome, AppError> {
|
||||
let listed = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let reply = serde_json::to_string(&listed.agents)
|
||||
.map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?;
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("listed {} agent(s)", listed.agents.len()),
|
||||
reply: Some(reply),
|
||||
})
|
||||
}
|
||||
|
||||
/// `stop_agent`: translate the agent name → its live session → `CloseTerminal`.
|
||||
async fn stop_agent(
|
||||
&self,
|
||||
|
||||
@ -29,7 +29,9 @@ use domain::ports::{
|
||||
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, SessionStrategy,
|
||||
};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
@ -342,6 +344,14 @@ struct FakeFs {
|
||||
writes: WriteLog<String>,
|
||||
created_dirs: Arc<Mutex<Vec<String>>>,
|
||||
project_context: Arc<Mutex<Option<String>>>,
|
||||
/// Paths reported as **already existing** by [`FileSystem::exists`] (the
|
||||
/// default empty set keeps the historical `exists == false` behaviour). Used
|
||||
/// by the MCP non-clobbering test.
|
||||
existing: Arc<Mutex<Vec<String>>>,
|
||||
/// Paths whose [`FileSystem::write`] must fail with an I/O error (nothing is
|
||||
/// recorded for them). Used by the MCP best-effort test to prove that an MCP
|
||||
/// config write failure never fails the launch.
|
||||
fail_writes_for: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
@ -351,11 +361,23 @@ impl FakeFs {
|
||||
writes: Arc::new(Mutex::new(Vec::new())),
|
||||
created_dirs: Arc::new(Mutex::new(Vec::new())),
|
||||
project_context: Arc::new(Mutex::new(None)),
|
||||
existing: Arc::new(Mutex::new(Vec::new())),
|
||||
fail_writes_for: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
fn set_project_context(&self, content: &str) {
|
||||
*self.project_context.lock().unwrap() = Some(content.to_owned());
|
||||
}
|
||||
/// Marks a path as already existing on disk, so [`FileSystem::exists`] returns
|
||||
/// `true` for it (non-clobbering scenarios).
|
||||
fn mark_existing(&self, path: &str) {
|
||||
self.existing.lock().unwrap().push(path.to_owned());
|
||||
}
|
||||
/// Makes any [`FileSystem::write`] whose path ends with `suffix` fail
|
||||
/// (best-effort scenarios — e.g. the MCP `.mcp.json` config file).
|
||||
fn fail_write_suffix(&self, suffix: &str) {
|
||||
self.fail_writes_for.lock().unwrap().push(suffix.to_owned());
|
||||
}
|
||||
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.writes.lock().unwrap().clone()
|
||||
}
|
||||
@ -401,15 +423,26 @@ impl FileSystem for FakeFs {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
let p = path.as_str();
|
||||
if self
|
||||
.fail_writes_for
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|suffix| p.ends_with(suffix.as_str()))
|
||||
{
|
||||
return Err(FsError::Io(format!("forced write failure for {p}")));
|
||||
}
|
||||
self.trace.lock().unwrap().push("fs.write".to_owned());
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((path.as_str().to_owned(), data.to_vec()));
|
||||
.push((p.to_owned(), data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(false)
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
let p = path.as_str();
|
||||
Ok(self.existing.lock().unwrap().iter().any(|e| e == p))
|
||||
}
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.created_dirs
|
||||
@ -1448,6 +1481,200 @@ async fn launch_unknown_profile_is_not_found() {
|
||||
assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LaunchAgent — MCP config injection (M1, cadrage v3 Décision 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds a CLAUDE.md-convention profile carrying an [`McpCapability`], so the
|
||||
/// MCP injection path (`apply_mcp_config`) is exercised during a launch.
|
||||
fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
|
||||
profile(id, ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_mcp(McpCapability::new(config, McpTransport::Stdio))
|
||||
}
|
||||
|
||||
/// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`).
|
||||
fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec<u8>)> {
|
||||
fs.writes()
|
||||
.into_iter()
|
||||
.filter(|(p, _)| p.ends_with(suffix))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() {
|
||||
// Test 1 — profile.mcp = None ⇒ no MCP file written, spec.args/env unchanged.
|
||||
// The profile has empty args and the runtime adds no env, so a clean launch
|
||||
// must leave the spawned spec's args/env exactly empty (no MCP flag/env).
|
||||
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
// No MCP-style config file anywhere.
|
||||
assert!(
|
||||
writes_ending_with(&fs, "/.mcp.json").is_empty(),
|
||||
"no MCP config file when profile.mcp is None"
|
||||
);
|
||||
// Exactly the convention file write (+ the Claude seed), nothing MCP-related.
|
||||
assert_eq!(
|
||||
fs.context_writes().len(),
|
||||
1,
|
||||
"only the convention file is written"
|
||||
);
|
||||
|
||||
// Spec args/env carry nothing MCP-related (here: empty).
|
||||
let spawns = pty.spawns();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
assert!(spawns[0].args.is_empty(), "no MCP flag pushed to args");
|
||||
assert!(spawns[0].env.is_empty(), "no MCP var pushed to env");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
|
||||
// Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at
|
||||
// <run_dir>/.mcp.json with a non-empty, coherent MCP server declaration.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let mcp = writes_ending_with(&fs, "/.mcp.json");
|
||||
assert_eq!(mcp.len(), 1, "exactly one MCP config file written");
|
||||
assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir");
|
||||
|
||||
let body = String::from_utf8(mcp[0].1.clone()).unwrap();
|
||||
assert!(!body.trim().is_empty(), "MCP declaration is non-empty");
|
||||
// Coherent MCP server declaration (an `mcpServers`/`idea` server entry).
|
||||
assert!(
|
||||
body.contains("mcpServers") && body.contains("idea"),
|
||||
"MCP declaration must declare the IdeA MCP server, got: {body}"
|
||||
);
|
||||
// It is valid JSON.
|
||||
let _: serde_json::Value =
|
||||
serde_json::from_str(&body).expect("MCP declaration is valid JSON");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_is_non_clobbering() {
|
||||
// Test 3 — if <run_dir>/.mcp.json already exists, the launch must NOT overwrite
|
||||
// it: no write is recorded against that path.
|
||||
let profile = profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
// Pre-existing MCP config file on disk.
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(
|
||||
writes_ending_with(&fs, "/.mcp.json").is_empty(),
|
||||
"an existing MCP config file must not be clobbered"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_write_failure_is_best_effort() {
|
||||
// Test 4 — a write failure on the MCP config file must NOT fail the launch.
|
||||
let profile = profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
);
|
||||
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
// Force the MCP config write to fail.
|
||||
fs.fail_write_suffix("/.mcp.json");
|
||||
|
||||
// The launch still succeeds and spawns the CLI.
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("MCP write failure must not fail the launch");
|
||||
assert!(out.structured.is_none());
|
||||
assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
|
||||
// by the run-dir config path.
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let spawns = pty.spawns();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
let args = &spawns[0].args;
|
||||
let pos = args
|
||||
.iter()
|
||||
.position(|a| a == "--mcp-config")
|
||||
.expect("the MCP flag must be present in args");
|
||||
assert_eq!(
|
||||
args.get(pos + 1),
|
||||
Some(&run_dir),
|
||||
"the MCP flag is followed by the run-dir config path"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_env_appends_var_and_path_to_env() {
|
||||
// Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, <run_dir>).
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let spawns = pty.spawns();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
assert!(
|
||||
spawns[0]
|
||||
.env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "IDEA_MCP" && v == &run_dir),
|
||||
"spec.env must carry (IDEA_MCP, run_dir), got: {:?}",
|
||||
spawns[0].env
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LaunchAgent — session resume (T4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user