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>
836 lines
32 KiB
Rust
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));
|
|
}
|
|
}
|