Files
IdeA/crates/domain/src/profile.rs
Blomios fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00

836 lines
32 KiB
Rust

//! AI runtime profile and its context-injection strategy.
//!
//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI
//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`]
//! port is parameterised by an [`AgentProfile`].
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::ProfileId;
/// Strategy for injecting an agent's `.md` context into the launched CLI.
///
/// Invariants:
/// - `ConventionFile.target` is a relative file name without `..` and not absolute,
/// - `Env.var` is a valid environment-variable identifier,
/// - `Flag.flag` is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum ContextInjection {
/// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`).
ConventionFile {
/// Relative target file name.
target: String,
},
/// Pass the context file path through a CLI flag.
Flag {
/// The flag template (e.g. `--context-file {path}` or `-f`).
flag: String,
},
/// Pipe the Markdown content on stdin.
Stdin,
/// Pass the context via an environment variable.
Env {
/// Environment variable name.
var: String,
},
}
impl ContextInjection {
/// Validated `ConventionFile` constructor.
///
/// # Errors
/// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or
/// contains `..`.
pub fn convention_file(target: impl Into<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::ConventionFile { target })
}
/// Validated `Flag` constructor.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `flag` is empty.
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
let flag = flag.into();
crate::validation::non_empty(&flag, "contextInjection.flag")?;
Ok(Self::Flag { flag })
}
/// `Stdin` constructor (no validation needed).
#[must_use]
pub const fn stdin() -> Self {
Self::Stdin
}
/// Validated `Env` constructor.
///
/// # Errors
/// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier.
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
let var = var.into();
crate::validation::valid_env_var(&var)?;
Ok(Self::Env { var })
}
}
/// Declares how IdeA assigns and resumes a CLI conversation, without parsing
/// the model's output (universal, declarative — see CONTEXT.md §9).
///
/// Optional on a profile: a profile without a `session` block behaves as today
/// (no resume).
///
/// Invariants:
/// - `resume_flag` is non-empty,
/// - if `assign_flag` is `Some`, it is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionStrategy {
/// Flag passed on the FIRST launch with a UUID generated by IdeA
/// (e.g. `"--session-id"`). `None` => degraded mode (resume without id).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assign_flag: Option<String>,
/// Resume flag (e.g. `"--resume"` or `"--continue"`).
pub resume_flag: String,
}
impl SessionStrategy {
/// Builds a validated session strategy.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `resume_flag` is empty, or if
/// `assign_flag` is `Some` but empty.
pub fn new(
assign_flag: Option<String>,
resume_flag: impl Into<String>,
) -> Result<Self, DomainError> {
let resume_flag = resume_flag.into();
crate::validation::non_empty(&resume_flag, "session.resumeFlag")?;
if let Some(flag) = &assign_flag {
crate::validation::non_empty(flag, "session.assignFlag")?;
}
Ok(Self {
assign_flag,
resume_flag,
})
}
}
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
///
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
/// adapter le pilote en mode programmatique. Ajouter un moteur structuré = une
/// variante ici + un adapter infra (`infrastructure/src/session/`), sans toucher
/// au cœur. Un profil **sans** `structured_adapter` reste un profil **TUI/PTY**
/// (terminal brut, comportement historique).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StructuredAdapter {
/// Piloté par `ClaudeSdkSession` (mode `-p --output-format stream-json` / SDK).
Claude,
/// Piloté par `CodexExecSession` (`codex exec` structuré).
Codex,
}
impl StructuredAdapter {
/// Clé **stable par famille de moteur** (provider), indépendante de l'uuid
/// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap
/// d'un profil vers un autre profil de **la même famille** réutilise la même
/// entrée (resumable partagé), et le fichier reste lisible à l'œil.
///
/// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation
/// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce
/// mapping ne doit pas dériver si la représentation serde change).
#[must_use]
pub const fn provider_key(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
}
}
}
/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
///
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
/// Déclaratif, Open/Closed (comme [`StructuredAdapter`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport {
/// Pont stdio (défaut) : IdeA parle au serveur MCP via stdin/stdout.
#[default]
Stdio,
/// Socket partagé (adresse) : optimisation, point ouvert (spike S-MCP).
Socket,
}
/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI
/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code,
/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9).
///
/// Invariants (garantis par les constructeurs) :
/// - `ConfigFile.target` est un chemin relatif sûr (pas de `..`, pas d'absolu),
/// - `Flag.flag` est non vide,
/// - `Env.var` est un identifiant de variable d'environnement valide.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum McpConfigStrategy {
/// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1)
/// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`).
ConfigFile {
/// Chemin relatif sûr du fichier de conf MCP.
target: String,
},
/// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`).
Flag {
/// Le flag (template) non vide.
flag: String,
},
/// Passer via une variable d'environnement.
Env {
/// Nom de la variable d'environnement.
var: String,
},
}
impl McpConfigStrategy {
/// Constructeur validé `ConfigFile`.
///
/// # Errors
/// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou
/// contient `..`.
pub fn config_file(target: impl Into<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::ConfigFile { target })
}
/// Constructeur validé `Flag`.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `flag` est vide.
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
let flag = flag.into();
crate::validation::non_empty(&flag, "mcp.config.flag")?;
Ok(Self::Flag { flag })
}
/// Constructeur validé `Env`.
///
/// # Errors
/// Renvoie [`DomainError::InvalidEnvVar`] si `var` n'est pas un identifiant
/// valide.
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
let var = var.into();
crate::validation::valid_env_var(&var)?;
Ok(Self::Env { var })
}
}
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` +
/// prose (comportement actuel, zéro régression).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapability {
/// Comment matérialiser la config MCP au lancement (relatif au run dir).
pub config: McpConfigStrategy,
/// Transport du serveur MCP IdeA (détail invisible au domaine).
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation.
#[serde(default)]
pub transport: McpTransport,
}
impl McpCapability {
/// Construit une capacité MCP. La validation est portée par le constructeur
/// de [`McpConfigStrategy`] (parse, don't validate) ; `transport` n'a pas
/// d'invariant.
#[must_use]
pub const fn new(config: McpConfigStrategy, transport: McpTransport) -> Self {
Self { config, transport }
}
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
/// - `name` and `command` non-empty,
/// - `context_injection` is itself valid (guaranteed by its constructors),
/// - `session` is itself valid (guaranteed by its constructor).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProfile {
/// Stable identifier.
pub id: ProfileId,
/// Display name.
pub name: String,
/// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`).
pub command: String,
/// Static arguments.
pub args: Vec<String>,
/// Context-injection strategy.
pub context_injection: ContextInjection,
/// Optional detection command (e.g. `claude --version`).
pub detect: Option<String>,
/// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
/// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` directory,
/// **never** the project root, so that N agents of the same profile never
/// collide on a single conventional file (`CLAUDE.md`, …) at the root.
pub cwd_template: String,
/// Optional conversation assign/resume strategy. Absent (legacy / no resume)
/// keeps today's behaviour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<SessionStrategy>,
/// Adapter d'exécution **structurée** (ARCHITECTURE §17). `None` ⇒ agent
/// **TUI/PTY** (cellule terminal brut, comportement historique). `Some(_)` ⇒
/// agent **IA structuré** (cellule chat + port [`crate::ports::AgentSession`]).
/// Open/Closed : ajouter un moteur = une variante + un adapter.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans adapter sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_adapter: Option<StructuredAdapter>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
/// l'agent voit les outils `idea_*`. Additif, Open/Closed.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
/// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5).
///
/// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral**
/// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il
/// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle`
/// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux
/// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le
/// **premier** des deux qui arrive libère le tour.
///
/// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est
/// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt
/// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
/// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`).
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans motif sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_ready_pattern: Option<String>,
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
/// la TUI), puis **cette séquence seule** après un court délai.
///
/// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
/// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
/// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
/// sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_sequence: Option<String>,
/// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
/// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
///
/// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
/// en dur dans le domaine.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_delay_ms: Option<u32>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
///
/// Declarative, Open/Closed: a new engine family is a new variant *only* if it
/// changes the adapter's dispatch; otherwise it is pure data on the profile
/// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None):
/// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EmbedderStrategy {
/// A local ONNX model run in-process (real integration is a follow-up).
LocalOnnx,
/// A local embedding server reached over HTTP (real integration is a follow-up).
LocalServer,
/// A remote embedding API (real integration is a follow-up).
Api,
/// No embedding engine: recall stays the dependency-free naïve étage 1.
None,
}
/// Declarative configuration for one embedding engine (LOT C, étage 2).
///
/// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json`
/// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed).
///
/// Invariants:
/// - `id` and `name` non-empty,
/// - `dimension` is non-zero (an embedder always produces fixed-length vectors).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedderProfile {
/// Stable identifier (e.g. `"local-onnx-minilm"`).
pub id: String,
/// Display name.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier (e.g. an ONNX model name), when the strategy needs one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// Name of the environment variable carrying the API key (never the key
/// itself), for an `api` strategy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces.
pub dimension: usize,
}
impl EmbedderProfile {
/// Builds a validated embedder profile.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `id` or `name` is empty,
/// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`.
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
strategy: EmbedderStrategy,
model: Option<String>,
endpoint: Option<String>,
api_key_env: Option<String>,
dimension: usize,
) -> Result<Self, DomainError> {
let id = id.into();
let name = name.into();
crate::validation::non_empty(&id, "embedder.id")?;
crate::validation::non_empty(&name, "embedder.name")?;
if dimension == 0 {
return Err(DomainError::EmptyField {
field: "embedder.dimension",
});
}
Ok(Self {
id,
name,
strategy,
model,
endpoint,
api_key_env,
dimension,
})
}
/// A dependency-free default profile: strategy [`EmbedderStrategy::None`].
/// Recall stays the naïve étage 1 — nothing is imposed.
#[must_use]
pub fn none() -> Self {
Self {
id: "none".to_owned(),
name: "None".to_owned(),
strategy: EmbedderStrategy::None,
model: None,
endpoint: None,
api_key_env: None,
dimension: 1,
}
}
}
impl AgentProfile {
/// Builds a validated profile.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `name` or `command` is empty.
#[allow(clippy::too_many_arguments)]
pub fn new(
id: ProfileId,
name: impl Into<String>,
command: impl Into<String>,
args: Vec<String>,
context_injection: ContextInjection,
detect: Option<String>,
cwd_template: impl Into<String>,
session: Option<SessionStrategy>,
) -> Result<Self, DomainError> {
let name = name.into();
let command = command.into();
let cwd_template = cwd_template.into();
crate::validation::non_empty(&name, "profile.name")?;
crate::validation::non_empty(&command, "profile.command")?;
Ok(Self {
id,
name,
command,
args,
context_injection,
detect,
cwd_template,
session,
structured_adapter: None,
mcp: None,
prompt_ready_pattern: None,
submit_sequence: None,
submit_delay_ms: None,
})
}
/// Builder : fixe l'[`StructuredAdapter`] d'exécution structurée (§17) et
/// renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
/// d'appel) : les profils TUI/PTY ne l'appellent simplement pas.
#[must_use]
pub fn with_structured_adapter(mut self, adapter: StructuredAdapter) -> Self {
self.structured_adapter = Some(adapter);
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas.
#[must_use]
pub fn with_mcp(mut self, mcp: McpCapability) -> Self {
self.mcp = Some(mcp);
self
}
/// Builder : fixe le motif de **retour-de-prompt** (§6, lot C5) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans détection de prompt ne l'appellent simplement pas.
#[must_use]
pub fn with_prompt_ready_pattern(mut self, pattern: impl Into<String>) -> Self {
self.prompt_ready_pattern = Some(pattern.into());
self
}
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
#[must_use]
pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
self.submit_sequence = Some(sequence.into());
self
}
/// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
#[must_use]
pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
self.submit_delay_ms = Some(delay_ms);
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
/// est pilotable en mode structuré. C'est exactement le prédicat que
/// l'`AgentSessionFactory` utilise pour décider qu'il sait piloter le profil
/// (`supports`) : les deux doivent rester d'accord.
///
/// Ceci ne restreint **que** ce qui est offert à la création : un profil
/// sans adapter reste un profil PTY/legacy valide, persistable et exécutable
/// (zéro régression sur l'existant).
#[must_use]
pub fn is_selectable(&self) -> bool {
self.structured_adapter.is_some()
}
}
#[cfg(test)]
mod mcp_tests {
use super::*;
use crate::ids::ProfileId;
/// A reference profile without any MCP capability (the historical shape).
fn profile_without_mcp() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::nil()),
"Dev",
"claude",
vec!["-p".to_owned()],
ContextInjection::convention_file("CLAUDE.md").expect("valid target"),
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("valid profile")
}
// -- Critère 1 : zéro régression de sérialisation (mcp = None) --------------
#[test]
fn profile_without_mcp_omits_mcp_key_in_json() {
let profile = profile_without_mcp();
assert!(profile.mcp.is_none());
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
!json.contains("\"mcp\""),
"a profile without MCP must NOT serialise an `mcp` key (zero regression); got: {json}"
);
}
#[test]
fn profile_without_mcp_round_trips_identically() {
let profile = profile_without_mcp();
let json = serde_json::to_string(&profile).expect("serialise");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert!(back.mcp.is_none());
}
#[test]
fn legacy_json_without_mcp_field_deserialises_to_none() {
// JSON produced before the `mcp` field existed: no `mcp` key at all.
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.mcp.is_none());
}
// -- Critère 2 : round-trip avec MCP (les 3 stratégies) ---------------------
#[test]
fn profile_with_mcp_config_file_round_trips() {
let cap = McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
McpTransport::Stdio,
);
let profile = profile_without_mcp().with_mcp(cap.clone());
assert_eq!(profile.mcp, Some(cap));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("\"mcp\""), "mcp key must be present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
}
#[test]
fn mcp_capability_flag_strategy_round_trips() {
let cap = McpCapability::new(
McpConfigStrategy::flag("--mcp-config {path}").expect("valid flag"),
McpTransport::Socket,
);
let json = serde_json::to_string(&cap).expect("serialise");
let back: McpCapability = serde_json::from_str(&json).expect("deserialise");
assert_eq!(cap, back);
assert_eq!(back.transport, McpTransport::Socket);
assert!(matches!(back.config, McpConfigStrategy::Flag { .. }));
}
#[test]
fn mcp_capability_env_strategy_round_trips() {
let cap = McpCapability::new(
McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid env"),
McpTransport::Stdio,
);
let json = serde_json::to_string(&cap).expect("serialise");
let back: McpCapability = serde_json::from_str(&json).expect("deserialise");
assert_eq!(cap, back);
assert!(matches!(back.config, McpConfigStrategy::Env { .. }));
}
#[test]
fn mcp_config_strategy_uses_tagged_camel_case() {
// The `strategy` tag and camelCase rename are part of the wire contract.
let json =
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
.expect("serialise");
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
}
// -- Critère 4 : transport par défaut = Stdio -------------------------------
#[test]
fn transport_default_is_stdio() {
assert_eq!(McpTransport::default(), McpTransport::Stdio);
}
#[test]
fn mcp_capability_without_transport_defaults_to_stdio() {
// `transport` is `#[serde(default)]`: an MCP capability serialised without
// it must deserialise to Stdio.
let json = r#"{ "config": { "strategy": "configFile", "target": ".mcp.json" } }"#;
let cap: McpCapability = serde_json::from_str(json).expect("deserialise");
assert_eq!(cap.transport, McpTransport::Stdio);
}
// -- Critère 3 : validation des constructeurs -------------------------------
#[test]
fn config_file_rejects_absolute_target() {
let err = McpConfigStrategy::config_file("/etc/mcp.json").unwrap_err();
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}
#[test]
fn config_file_rejects_parent_traversal() {
let err = McpConfigStrategy::config_file("../escape/.mcp.json").unwrap_err();
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}
#[test]
fn config_file_accepts_safe_relative_target() {
let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
assert_eq!(
s,
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
}
);
}
#[test]
fn flag_rejects_empty() {
let err = McpConfigStrategy::flag("").unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
#[test]
fn flag_accepts_non_empty() {
let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
assert_eq!(
s,
McpConfigStrategy::Flag {
flag: "--mcp-config {path}".to_owned()
}
);
}
#[test]
fn env_rejects_invalid_name() {
let err = McpConfigStrategy::env("1BAD-NAME").unwrap_err();
assert!(matches!(err, DomainError::InvalidEnvVar { .. }));
}
#[test]
fn env_accepts_valid_name() {
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
assert_eq!(
s,
McpConfigStrategy::Env {
var: "IDEA_MCP_SERVER".to_owned()
}
);
}
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
#[test]
fn profile_default_has_no_prompt_ready_pattern() {
// Existing profiles (built via `new`) carry no pattern ⇒ no false Idle.
assert!(profile_without_mcp().prompt_ready_pattern.is_none());
}
#[test]
fn profile_without_prompt_pattern_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("promptReadyPattern"),
"a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_prompt_pattern_deserialises_to_none() {
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.prompt_ready_pattern.is_none());
}
#[test]
fn with_prompt_ready_pattern_sets_and_round_trips() {
let profile = profile_without_mcp().with_prompt_ready_pattern("\n> ");
assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> "));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("promptReadyPattern"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
}
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------
#[test]
fn profile_default_has_no_submit_fields() {
let p = profile_without_mcp();
assert!(p.submit_sequence.is_none());
assert!(p.submit_delay_ms.is_none());
}
#[test]
fn profile_without_submit_fields_omits_keys_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("submitSequence"),
"no submitSequence key when None (zero regression); got: {json}"
);
assert!(
!json.contains("submitDelayMs"),
"no submitDelayMs key when None (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_submit_fields_deserialises_to_none() {
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(p.submit_sequence.is_none());
assert!(p.submit_delay_ms.is_none());
}
#[test]
fn with_submit_fields_set_and_round_trip_camel_case() {
let p = profile_without_mcp()
.with_submit_sequence("\r")
.with_submit_delay_ms(60);
assert_eq!(p.submit_sequence.as_deref(), Some("\r"));
assert_eq!(p.submit_delay_ms, Some(60));
let json = serde_json::to_string(&p).expect("serialise");
assert!(json.contains("submitSequence"), "key present: {json}");
assert!(json.contains("submitDelayMs"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(p, back);
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
assert_eq!(back.submit_delay_ms, Some(60));
}
}