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

@ -71,6 +71,19 @@ pub enum DomainEvent {
/// `true` when a turn is in flight, `false` when the agent is idle.
busy: bool,
},
/// An agent's **liveness** (alive/stalled) changed (lot 2, chantier
/// readiness/heartbeat). Emitted **once per transition** by the stall detector:
/// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's
/// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool
/// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete,
/// low-frequency beacon (no spam) relayed to the front so the mediated-input view
/// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running.
AgentLivenessChanged {
/// The agent whose liveness changed.
agent_id: AgentId,
/// The new liveness state.
liveness: crate::input::AgentLiveness,
},
/// An agent's runtime profile was changed (hot-swap of the AI engine).
AgentProfileChanged {
/// The agent.

View File

@ -80,6 +80,29 @@ pub enum AgentBusyState {
},
}
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
/// chantier readiness/heartbeat — détection « Stalled »).
///
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
///
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
/// can badge a frozen agent without event spam. Derived from the per-agent
/// `last_seen_ms` heartbeat, never from parsing the model output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "liveness")]
pub enum AgentLiveness {
/// The agent is producing proof of liveness (deltas / tool activity /
/// heartbeats) within its `stall_after_ms` window — or is idle.
Alive,
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
/// (a late heartbeat flips it back to [`Self::Alive`]).
Stalled,
}
impl AgentBusyState {
/// Whether a turn is currently in flight.
#[must_use]
@ -195,6 +218,29 @@ pub trait InputMediator: Send + Sync {
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
/// agent is still working; a battement arriving after a `Stalled` transition flips it
/// back to [`AgentLiveness::Alive`].
///
/// Default: no-op (a mediator that does not track liveness). The infra adapter
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
fn mark_alive(&self, _agent: AgentId) {}
/// Declares the agent's **stall threshold** (its profile's
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
/// from the target's profile and calls this **before** the enqueue that starts a
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
/// zero regression).
///
/// Default: no-op (a mediator that does not track liveness).
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
/// The current [`AgentBusyState`] of `agent`.
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
}

View File

@ -47,6 +47,7 @@ pub mod orchestrator;
pub mod ports;
pub mod profile;
pub mod project;
pub mod readiness;
pub mod remote;
pub mod skill;
pub mod template;
@ -74,7 +75,8 @@ pub use skill::{Skill, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
McpServerWiring, SessionStrategy,
};
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
@ -84,7 +86,9 @@ pub use conversation::{
ConversationSession, SessionRef, WaitForGraph,
};
pub use input::{AgentBusyState, InputMediator, InputSource};
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
pub use readiness::{ReadinessPolicy, ReadinessSignal};
pub use conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,

View File

@ -209,6 +209,17 @@ pub enum ReplyEvent {
/// Libellé humain-lisible de l'activité.
label: String,
},
/// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est
/// toujours en train de travailler. Émis par l'adapter quand le moteur signale
/// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de
/// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent
/// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1).
///
/// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale
/// comme un delta (ignoré par les consommateurs synchrones) et le flux continue
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
/// d'obligation d'en émettre.
Heartbeat,
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
/// Après `Final`, le flux se termine (plus aucun événement).
@ -220,8 +231,10 @@ pub enum ReplyEvent {
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
/// plutôt que des octets bruts.
/// mais **typé** : deltas de texte → activités d'outil → battements de cœur*
/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) →
/// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final`
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
// ---------------------------------------------------------------------------

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

View File

@ -0,0 +1,111 @@
//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier
//! readiness/heartbeat, lot 1).
//!
//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal
//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que
//! l'application puisse décider de marquer un agent `Idle`
//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe**
//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply`
//! explicite ou d'un sniff littéral de prompt PTY.
//!
//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage)
//!
//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par
//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`).
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]).
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé).
//!
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
//! elles existent dans le vocabulaire mais ne sont **pas** produites par
//! [`ReadinessPolicy::classify`] dans ce lot.
use crate::ports::ReplyEvent;
/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`]
/// déduit d'un événement observable du tour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadinessSignal {
/// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`.
/// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et
/// marquer l'agent `Idle`.
TurnEnded,
/// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit
/// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il
/// est porté par le chemin de délégation, présent ici pour compléter le
/// vocabulaire et le rendre explicite.
ExplicitReply,
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par
/// `classify`, présent pour nommer le signal de repli legacy.
PromptReady,
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
/// réservée au **lot 2** — non produit dans ce lot.
Stalled,
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
/// produit dans ce lot.
TimedOut,
}
/// Politique **pure** de classification d'un événement de tour en
/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de
/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? »
/// vive dans le **domaine** et reste testable sans process ni réseau.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReadinessPolicy;
impl ReadinessPolicy {
/// Classe un [`ReplyEvent`] en signal de readiness.
///
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
/// événement terminal, il signe la fin de tour déterministe.
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
#[must_use]
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
match event {
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. }
| ReplyEvent::Heartbeat => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_classifies_as_turn_ended() {
let ev = ReplyEvent::Final {
content: "fini".to_owned(),
};
assert_eq!(
ReadinessPolicy::classify(&ev),
Some(ReadinessSignal::TurnEnded)
);
}
#[test]
fn deltas_activities_and_heartbeats_are_non_terminal() {
assert_eq!(
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
None
);
assert_eq!(
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
None
);
assert_eq!(
ReadinessPolicy::classify(&ReplyEvent::Heartbeat),
None,
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
);
}
}