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:
2026-06-14 09:28:44 +02:00
parent fdcf16c387
commit 0f8ba38d51
24 changed files with 2745 additions and 156 deletions

View File

@ -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}"
);
}
}