Files
IdeA/crates/domain/src/profile.rs
Blomios 744de20f4b feat(orchestrator): backstop no-reply du rendez-vous inter-agents
Remédiation du wedge persistant après échec live du fix Finding A (77e62e5).
Détecte la fin de tour d'un agent sollicité qui n'a pas appelé idea_reply et
débloque l'agent demandeur au lieu de le laisser en attente indéfinie.

Ajoute le suivi de tour côté inspector Claude (claude_turn_watcher) et la
résolution des chemins de session (claude_paths), câblés dans le rendez-vous
idea_ask_agent ⇄ idea_reply.

Build workspace vert, suite complète verte, zéro warning.
Backstop NON prouvé levé en live : merge develop interdit tant que la levée
du wedge n'est pas validée en conditions réelles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:50:58 +02:00

1515 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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;
use crate::permission::ProjectorKey;
/// 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,
})
}
}
/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée
/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code
/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`].
///
/// Deux seuils optionnels :
/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité /
/// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué**
/// (`ReadinessSignal::Stalled`) — détection au lot 2 ;
/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou
/// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2.
///
/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place
/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au
/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LivenessStrategy {
/// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué.
/// `None` ⇒ pas de détection de stagnation (lot 2).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stall_after_ms: Option<u32>,
/// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de
/// garde-fou par profil (lot 2).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_timeout_ms: Option<u32>,
}
impl LivenessStrategy {
/// Construit une stratégie de vivacité validée (parse-don't-validate, comme
/// [`SessionStrategy::new`]).
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de
/// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »).
pub const fn new(
stall_after_ms: Option<u32>,
turn_timeout_ms: Option<u32>,
) -> Result<Self, DomainError> {
if let Some(0) = stall_after_ms {
return Err(DomainError::EmptyField {
field: "liveness.stallAfterMs",
});
}
if let Some(0) = turn_timeout_ms {
return Err(DomainError::EmptyField {
field: "liveness.turnTimeoutMs",
});
}
Ok(Self {
stall_after_ms,
turn_timeout_ms,
})
}
}
/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
///
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), un motif déclaratif
/// littéral **plus riche** qu'une simple sous-chaîne : là où un sigil de prompt
/// serait une simple sous-chaîne littérale, la limite de session a
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
/// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée
/// ne franchit la frontière domaine : l'infra émet un
/// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée.
///
/// Invariant (garanti par le constructeur) : `pattern` est non vide.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RateLimitPattern {
/// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la
/// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile
/// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2).
pub pattern: String,
/// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait
/// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset
/// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet
/// humain). Donnée opaque au domaine.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reset_capture: Option<String>,
/// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la
/// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose
/// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une
/// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par
/// défaut. Donnée opaque au domaine.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_format: Option<String>,
}
impl RateLimitPattern {
/// Construit un motif validé (parse-don't-validate, comme
/// [`SessionStrategy::new`] / [`LivenessStrategy::new`]).
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si
/// `pattern` est vide.
pub fn new(
pattern: impl Into<String>,
reset_capture: Option<String>,
time_format: Option<String>,
) -> Result<Self, DomainError> {
let pattern = pattern.into();
crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?;
Ok(Self {
pattern,
reset_capture,
time_format,
})
}
}
/// 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,
},
/// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé
/// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable
/// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI
/// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex
/// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table
/// TOML `[mcp_servers.<nom>]`. IdeA écrit ce `config.toml` DANS le run dir de
/// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au
/// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude).
TomlConfigHome {
/// Chemin relatif sûr du fichier `config.toml` (convention :
/// `".codex/config.toml"`).
target: String,
/// Nom de la variable d'environnement pointée sur le **dossier parent** de
/// `target` (ex. `"CODEX_HOME"`).
home_env: 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 })
}
/// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]).
///
/// # Errors
/// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..`
/// (même validation que [`Self::config_file`]),
/// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de
/// variable d'environnement valide.
pub fn toml_config_home(
target: impl Into<String>,
home_env: impl Into<String>,
) -> Result<Self, DomainError> {
let target = target.into();
let home_env = home_env.into();
crate::validation::relative_safe(&target)?;
crate::validation::valid_env_var(&home_env)?;
Ok(Self::TomlConfigHome { target, home_env })
}
}
/// 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 }
}
}
/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`),
/// factorisée pour servir de **source unique** aux deux sérialisations qui en
/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json`
/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML).
///
/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une
/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml`
/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le
/// domaine ne fait que la **mise en forme**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServerWiring {
/// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en
/// déclaration minimale dégradée).
pub command: String,
/// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`).
pub args: Vec<String>,
/// Transport du serveur MCP (surfacé dans les deux formats).
pub transport: McpTransport,
}
impl McpServerWiring {
/// Codex's MCP client defaults tool calls to a short interactive timeout (120s on
/// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous
/// that can legitimately span a full delegated dev/test turn, so the generated
/// server config must widen the per-tool timeout.
pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60;
/// Construit le wiring depuis ses parties.
#[must_use]
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
Self {
command,
args,
transport,
}
}
/// Étiquette stable du transport, identique pour les deux formats.
#[must_use]
const fn transport_label(&self) -> &'static str {
match self.transport {
McpTransport::Stdio => "stdio",
McpTransport::Socket => "socket",
}
}
/// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) :
/// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est
/// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent
/// valides).
#[must_use]
pub fn to_mcp_json(&self) -> String {
let command = json_string(&self.command);
let args = if self.args.is_empty() {
String::new()
} else {
let joined = self
.args
.iter()
.map(|a| format!("\n {}", json_string(a)))
.collect::<Vec<_>>()
.join(",");
format!("{joined}\n ")
};
let transport = self.transport_label();
format!(
r#"{{
"mcpServers": {{
"idea": {{
"command": {command},
"args": [{args}],
"transport": "{transport}"
}}
}}
}}
"#
)
}
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
/// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique
/// des outils IdeA. Chaque chaîne est échappée en chaîne basique TOML
/// (équivalent du `json_string` : espaces/backslash/quotes restent valides).
#[must_use]
pub fn to_config_toml(&self) -> String {
let command = toml_string(&self.command);
let args = self
.args
.iter()
.map(|a| toml_string(a))
.collect::<Vec<_>>()
.join(", ");
let transport = self.transport_label();
let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
format!(
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n"
)
}
}
/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins
/// exe/endpoint avec espaces, backslash ou quotes.
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes
/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`,
/// `\`, et les contrôles.
fn toml_string(s: &str) -> String {
// Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de
// JSON pour les caractères qui nous concernent (chemins, flags).
json_string(s)
}
/// 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>,
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
/// timeout de tour du lot 2).
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub liveness: Option<LivenessStrategy>,
/// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE
/// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune**
/// détection par motif : seuls les agents structurés (niveau 1) ou le filet
/// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans
/// adapter structuré**, IdeA observe la sortie et émet un
/// [`crate::ports::ReplyEvent::RateLimited`] sur match.
///
/// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex
/// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro
/// préservé.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_pattern: Option<RateLimitPattern>,
/// 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>,
/// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter
/// traduit les [`crate::permission::EffectivePermissions`] résolues en config
/// de permission concrète de cette CLI au lancement. Donnée **déclarative**
/// (Open/Closed), calquée sur [`StructuredAdapter`].
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection :
/// la CLI garde son prompting natif (invariant produit de
/// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de
/// permission de cette CLI au lancement.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projector: Option<ProjectorKey>,
}
/// 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,
liveness: None,
rate_limit_pattern: None,
submit_sequence: None,
submit_delay_ms: None,
projector: 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 la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans réglage de vivacité ne l'appellent simplement pas.
#[must_use]
pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self {
self.liveness = Some(liveness);
self
}
/// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2)
/// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
/// d'appel) : les profils sans détection par motif ne l'appellent simplement pas.
#[must_use]
pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self {
self.rate_limit_pattern = Some(pattern);
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
}
/// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans projection de permission ne l'appellent simplement pas.
#[must_use]
pub const fn with_projector(mut self, projector: ProjectorKey) -> Self {
self.projector = Some(projector);
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()
}
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
/// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible.
///
/// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si
/// IdeA sait écrire la config que **cette** CLI lit nativement :
/// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json`
/// dans son cwd (run dir isolé) ;
/// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`,
/// qu'IdeA isole dans le run dir via `home_env` ;
/// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier
/// `.ideai/requests` + prose, le pont natif n'est pas branché.
///
/// Cette fonction centralise le critère pour que la garde applicative
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
#[must_use]
pub fn materializes_idea_bridge(&self) -> bool {
match (
self.structured_adapter,
self.mcp.as_ref().map(|c| &c.config),
) {
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
target == ".mcp.json"
}
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
true
}
_ => false,
}
}
}
#[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()
}
);
}
// -- Backstop no-reply : le champ `promptReadyPattern` a été retiré ----------
#[test]
fn legacy_json_with_obsolete_prompt_ready_pattern_is_ignored() {
// Le watcher prompt-ready PTY est supprimé (remplacé par le TurnWatcher
// transcript). Un `profiles.json` antérieur portant encore la clé doit
// continuer à se désérialiser (pas de `deny_unknown_fields`) : la clé obsolète
// est simplement ignorée — zéro régression de lecture des profils existants.
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}",
"promptReadyPattern": "? for shortcuts"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert_eq!(profile.command, "claude");
}
// -- §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));
}
// -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation --
#[test]
fn profile_default_has_no_liveness() {
// Profils existants (via `new`) : aucun réglage de vivacité.
assert!(profile_without_mcp().liveness.is_none());
}
#[test]
fn profile_without_liveness_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("liveness"),
"a profile without liveness must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_liveness_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.liveness.is_none());
}
#[test]
fn with_liveness_sets_and_round_trips_camel_case() {
let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness");
let profile = profile_without_mcp().with_liveness(liveness);
assert_eq!(profile.liveness, Some(liveness));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("liveness"), "key present: {json}");
assert!(json.contains("stallAfterMs"), "camelCase field: {json}");
assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.liveness, Some(liveness));
}
#[test]
fn liveness_omits_unset_thresholds_in_json() {
// Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise.
let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness");
let json = serde_json::to_string(&liveness).expect("serialise");
assert!(
!json.contains("stallAfterMs"),
"an unset stall threshold must be omitted; got: {json}"
);
assert!(
json.contains("turnTimeoutMs"),
"set threshold present: {json}"
);
}
#[test]
fn liveness_new_rejects_zero_thresholds() {
assert!(matches!(
LivenessStrategy::new(Some(0), None).unwrap_err(),
DomainError::EmptyField { .. }
));
assert!(matches!(
LivenessStrategy::new(None, Some(0)).unwrap_err(),
DomainError::EmptyField { .. }
));
// Les deux None : valide (= « aucun seuil »).
assert!(LivenessStrategy::new(None, None).is_ok());
}
// -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ----------
#[test]
fn toml_config_home_round_trips_with_tagged_strategy() {
let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
.expect("valid toml config home");
let json = serde_json::to_string(&strategy).expect("serialise");
// Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields.
assert!(
json.contains("\"strategy\":\"tomlConfigHome\""),
"tagged camelCase variant expected; got: {json}"
);
assert!(
json.contains("\"target\":\".codex/config.toml\""),
"target field expected; got: {json}"
);
// NB: `rename_all = "camelCase"` on this enum renames *variants*, not the
// fields of a struct variant, so `home_env` stays snake_case on the wire.
assert!(
json.contains("\"home_env\":\"CODEX_HOME\""),
"home_env field expected; got: {json}"
);
let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise");
assert_eq!(strategy, back);
}
#[test]
fn toml_config_home_rejects_absolute_and_parent_target() {
let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err();
assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. }));
let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err();
assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. }));
}
#[test]
fn toml_config_home_rejects_invalid_home_env() {
// Empty home_env: first char is None ⇒ invalid identifier.
let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err();
assert!(matches!(empty, DomainError::InvalidEnvVar { .. }));
// Illegal character in the env var name.
let illegal =
McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err();
assert!(matches!(illegal, DomainError::InvalidEnvVar { .. }));
}
#[test]
fn materializes_idea_bridge_matrix() {
// Claude + `.mcp.json` ConfigFile ⇒ bridge materialised.
let claude = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
McpTransport::Stdio,
));
assert!(claude.materializes_idea_bridge());
// Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to
// be refused before this surface existed).
let codex = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Codex)
.with_mcp(McpCapability::new(
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
.expect("valid toml config home"),
McpTransport::Stdio,
));
assert!(codex.materializes_idea_bridge());
// Codex WITHOUT any MCP capability ⇒ no bridge.
let codex_no_mcp = profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
assert!(!codex_no_mcp.materializes_idea_bridge());
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
let codex_wrong_strategy = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Codex)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
McpTransport::Stdio,
));
assert!(!codex_wrong_strategy.materializes_idea_bridge());
}
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
#[test]
fn profile_default_has_no_projector() {
// Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif.
assert!(profile_without_mcp().projector.is_none());
}
#[test]
fn profile_without_projector_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("projector"),
"a profile without a projector must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_projector_deserialises_to_none() {
// JSON produit avant l'existence du champ `projector` : aucune clé `projector`.
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.projector.is_none());
}
#[test]
fn with_projector_sets_and_round_trips_camel_case() {
let profile = profile_without_mcp().with_projector(ProjectorKey::Claude);
assert_eq!(profile.projector, Some(ProjectorKey::Claude));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
json.contains("\"projector\":\"claude\""),
"projector key present in stable wire form: {json}"
);
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.projector, Some(ProjectorKey::Claude));
}
#[test]
fn mcp_server_wiring_encodes_expected_toml() {
// A command path with a space and a backslash exercises TOML escaping.
let wiring = McpServerWiring::new(
"/opt/My Apps\\idea".to_owned(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
"/tmp/sock 1".to_owned(),
],
McpTransport::Stdio,
);
let toml = wiring.to_config_toml();
// Table header present.
assert!(
toml.contains("[mcp_servers.idea]"),
"table header expected; got: {toml}"
);
// Command path escaped as a TOML basic string (backslash doubled, space kept).
assert!(
toml.contains("command = \"/opt/My Apps\\\\idea\""),
"escaped command path expected; got: {toml}"
);
// Args preserved in order, as a TOML array.
assert!(
toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"),
"args in order expected; got: {toml}"
);
// Transport surfaced.
assert!(
toml.contains("transport = \"stdio\""),
"transport expected; got: {toml}"
);
// IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting
// `.mcp.json` path.
assert!(
toml.contains("default_tools_approval_mode = \"approve\""),
"IdeA MCP tools should not prompt for approval; got: {toml}"
);
assert!(
toml.contains("tool_timeout_sec = 86400"),
"IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
);
}
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
#[test]
fn rate_limit_pattern_new_rejects_empty_pattern() {
let err = RateLimitPattern::new("", None, None).unwrap_err();
assert!(
matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"),
"un motif vide doit être rejeté; got: {err:?}"
);
}
#[test]
fn rate_limit_pattern_new_accepts_non_empty_pattern() {
let p = RateLimitPattern::new(
"rate limit.*resets at (?P<reset>.+)",
Some("reset".to_owned()),
Some("%H:%M".to_owned()),
)
.expect("valid pattern");
assert_eq!(p.pattern, "rate limit.*resets at (?P<reset>.+)");
assert_eq!(p.reset_capture.as_deref(), Some("reset"));
assert_eq!(p.time_format.as_deref(), Some("%H:%M"));
}
#[test]
fn profile_default_has_no_rate_limit_pattern() {
// Profils existants (via `new`) : aucun motif de limite.
assert!(profile_without_mcp().rate_limit_pattern.is_none());
}
#[test]
fn profile_without_rate_limit_pattern_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("rateLimitPattern"),
"a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_rate_limit_pattern_deserialises_to_none() {
// JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`.
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.rate_limit_pattern.is_none());
}
#[test]
fn with_rate_limit_pattern_sets_and_round_trips_camel_case() {
let pattern = RateLimitPattern::new(
"limit reached, resets (?P<resetAt>.+)",
Some("resetAt".to_owned()),
Some("%-I%p".to_owned()),
)
.expect("valid pattern");
let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone());
assert_eq!(profile.rate_limit_pattern, Some(pattern));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("rateLimitPattern"), "key present: {json}");
// camelCase respecté sur les champs de RateLimitPattern.
assert!(
json.contains("resetCapture"),
"camelCase field resetCapture: {json}"
);
assert!(
json.contains("timeFormat"),
"camelCase field timeFormat: {json}"
);
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
}
#[test]
fn rate_limit_pattern_omits_unset_optional_fields_in_json() {
// reset_capture / time_format à None ⇒ leurs clés sont omises.
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
let json = serde_json::to_string(&pattern).expect("serialise");
assert!(
json.contains("\"pattern\""),
"pattern field present: {json}"
);
assert!(
!json.contains("resetCapture"),
"an unset resetCapture must be omitted; got: {json}"
);
assert!(
!json.contains("timeFormat"),
"an unset timeFormat must be omitted; got: {json}"
);
}
}