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>
1515 lines
60 KiB
Rust
1515 lines
60 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;
|
||
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}"
|
||
);
|
||
}
|
||
}
|