feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).
## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
toml_config_home(...); AgentProfile::materializes_idea_bridge()
(whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
+ encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
{runDir}/<target>, pousse (home_env, parent) dans spec.env);
guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
sur loopback réel (fakes, zéro token).
## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
mark_idle sur le signal déterministe; branché dans ask_agent.
Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -118,6 +118,61 @@ impl SessionStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||
///
|
||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||
@ -193,6 +248,22 @@ pub enum McpConfigStrategy {
|
||||
/// 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 {
|
||||
@ -227,6 +298,24 @@ impl McpConfigStrategy {
|
||||
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,
|
||||
@ -253,6 +342,128 @@ impl McpCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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`. 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();
|
||||
format!(
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\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:
|
||||
@ -316,6 +527,13 @@ pub struct AgentProfile {
|
||||
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
||||
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
||||
///
|
||||
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
|
||||
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
|
||||
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
|
||||
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
|
||||
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
|
||||
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
||||
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
||||
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
||||
@ -325,6 +543,15 @@ pub struct AgentProfile {
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// 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>,
|
||||
/// 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
|
||||
@ -483,6 +710,7 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
liveness: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
})
|
||||
@ -515,6 +743,15 @@ impl AgentProfile {
|
||||
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 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.
|
||||
@ -546,6 +783,33 @@ impl AgentProfile {
|
||||
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)]
|
||||
@ -832,4 +1096,200 @@ mod mcp_tests {
|
||||
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());
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user