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

@ -80,8 +80,11 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
.expect(".mcp.json is a valid relative MCP config target"),
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new(
@ -157,18 +160,36 @@ mod mcp_tests {
}
#[test]
fn claude_and_codex_mcp_use_config_file_mcp_json() {
for slug in ["claude", "codex"] {
let mcp = profile(slug).mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
},
"profile `{slug}` should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
}
fn claude_mcp_uses_config_file_mcp_json() {
let mcp = profile("claude").mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
},
"Claude should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
}
#[test]
fn codex_mcp_uses_toml_config_home_codex() {
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
let mcp = profile("codex").mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::TomlConfigHome {
target: ".codex/config.toml".to_owned(),
home_env: "CODEX_HOME".to_owned(),
},
"Codex should declare `.codex/config.toml` + CODEX_HOME"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
assert!(
profile("codex").materializes_idea_bridge(),
"the Codex seed must materialise the idea bridge"
);
}
#[test]

View File

@ -1746,6 +1746,36 @@ impl LaunchAgent {
},
}
}
domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => {
// Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table
// `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin
// `<run_dir>/<target>`, et on pousse `home_env` (ex. `CODEX_HOME`) vers
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
let path = RemotePath::new(join(run_dir, target));
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
// (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas
// écraser une déclaration réelle par la minimale.
match runtime {
Some(_) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
Err(_) => {}
},
}
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
let home_dir = parent_dir(run_dir, target);
spec.env.push((home_env.clone(), home_dir));
}
domain::profile::McpConfigStrategy::Flag { flag } => {
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
// config path is the run dir itself (the CLI's cwd), where the server
@ -1858,58 +1888,40 @@ fn mcp_server_declaration(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
) -> String {
// Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport
// is surfaced so a socket-based CLI can be wired later without changing this seam.
let transport_label = match transport {
domain::profile::McpTransport::Stdio => "stdio",
domain::profile::McpTransport::Socket => "socket",
};
// `command` + extra args depend on whether OS/runtime facts were injected.
// Each `args` entry is emitted as an escaped JSON string so an exe path or
// endpoint with spaces/backslashes/quotes stays valid JSON.
let (command, extra_args) = match runtime {
Some(rt) => {
let arg = |label: &str, value: &str| {
format!(
",\n {},\n {}",
json_string(label),
json_string(value)
)
};
let extra = format!(
"{}{}{}",
arg("--endpoint", &rt.endpoint),
arg("--project", &rt.project_id),
arg("--requester", &rt.requester),
);
(rt.exe.as_str(), extra)
}
None => ("idea", String::new()),
};
let command = json_string(command);
format!(
r#"{{
"mcpServers": {{
"idea": {{
"command": {command},
"args": [
"mcp-server"{extra_args}
],
"transport": "{transport_label}"
}}
}}
}}
"#
)
mcp_server_wiring(transport, runtime).to_mcp_json()
}
/// Wraps a string as a JSON string literal (quotes + escaping), reusing the path
/// escaper so an exe path / endpoint with spaces, backslashes or quotes stays valid
/// JSON in the generated `.mcp.json`.
fn json_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
/// every materialisation format (cadrage Codex D2). It is the **single source of
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
///
/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback
/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server`
/// fallback (orchestrator / hot-swap / tests).
#[must_use]
fn mcp_server_wiring(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
) -> domain::McpServerWiring {
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
domain::McpServerWiring::new(command, args, transport)
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
@ -1919,6 +1931,18 @@ fn join(base: &ProjectPath, rel: &str) -> String {
format!("{b}/{rel}")
}
/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
/// config file (`config.toml`), not the file itself. When `rel` has no separator
/// (file directly in the run dir), the parent is the run dir.
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
let full = join(base, rel);
match full.rsplit_once(['/', '\\']) {
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
}
}
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
/// root — guaranteeing that two distinct agents on the same project root get two

View File

@ -16,7 +16,7 @@ mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub(crate) use lifecycle::ReattachDecision;
pub use structured::send_blocking;
pub use structured::{drain_with_readiness, send_blocking};
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};

View File

@ -14,7 +14,10 @@
use std::time::Duration;
use domain::input::InputMediator;
use domain::ids::AgentId;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
@ -39,14 +42,83 @@ pub async fn send_blocking(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
) -> Result<String, AgentSessionError> {
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
}
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie
/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié
/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un
/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat,
/// lot 1, fix de la cause racine du blocage `Busy`).
///
/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ;
/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final`
/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via
/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté
/// orchestrateur.
///
/// # Errors
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
/// timeout).
pub async fn drain_with_readiness(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
mediator: &dyn InputMediator,
agent: AgentId,
) -> Result<String, AgentSessionError> {
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
// (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`.
drain_bounded_events(
session,
prompt,
timeout,
|event| {
// Tout événement **non terminal** prouve la vivacité ⇒ un battement.
if !matches!(event, ReplyEvent::Final { .. }) {
mediator.mark_alive(agent);
}
},
|signal| {
if signal == ReadinessSignal::TurnEnded {
mediator.mark_idle(agent);
}
},
)
.await
}
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant
/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut
/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`]
/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`).
///
/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness`
/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session
/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1).
async fn drain_bounded_events(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
on_event: impl FnMut(&ReplyEvent),
on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
match timeout {
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
Some(dur) => match tokio::time::timeout(
dur,
drain_to_final(session, prompt, on_event, on_signal),
)
.await
{
Ok(result) => result,
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
Err(_elapsed) => Err(AgentSessionError::Timeout),
},
None => drain_to_final(session, prompt).await,
None => drain_to_final(session, prompt, on_event, on_signal).await,
}
}
@ -56,18 +128,124 @@ pub async fn send_blocking(
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
///
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
async fn drain_to_final(
session: &dyn AgentSession,
prompt: &str,
mut on_event: impl FnMut(&ReplyEvent),
mut on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
let stream = session.send(prompt).await?;
for event in stream {
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).
on_event(&event);
if let Some(signal) = ReadinessPolicy::classify(&event) {
on_signal(signal);
}
if let ReplyEvent::Final { content } = event {
return Ok(content);
}
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
}
Err(AgentSessionError::Io(
"le flux de réponse s'est terminé sans événement Final".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use domain::ids::SessionId;
use domain::input::{AgentBusyState, SubmitConfig};
use domain::mailbox::{PendingReply, Ticket};
use domain::ports::PtyHandle;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
/// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
/// `Final`).
struct FakeSession {
events: Vec<ReplyEvent>,
}
#[async_trait::async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(1))
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(
&self,
_prompt: &str,
) -> Result<domain::ports::ReplyStream, AgentSessionError> {
Ok(Box::new(self.events.clone().into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
/// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`.
#[derive(Default)]
struct RecordingMediator {
calls: Mutex<Vec<&'static str>>,
}
impl InputMediator for RecordingMediator {
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
unreachable!("non utilisé par drain_with_readiness")
}
fn preempt(&self, _agent: AgentId) {}
fn mark_idle(&self, _agent: AgentId) {
self.calls.lock().unwrap().push("idle");
}
fn mark_alive(&self, _agent: AgentId) {
self.calls.lock().unwrap().push("alive");
}
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
AgentBusyState::Idle
}
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
fn bind_handle_with_prompt(
&self,
_agent: AgentId,
_handle: PtyHandle,
_pattern: Option<String>,
_submit: SubmitConfig,
) {
}
}
#[tokio::test]
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
let session = FakeSession {
events: vec![
ReplyEvent::TextDelta { text: "a".into() },
ReplyEvent::ToolActivity { label: "lit".into() },
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "fini".into(),
},
],
};
let mediator = RecordingMediator::default();
let out = drain_with_readiness(&session, "go", None, &mediator, agent(1))
.await
.expect("drain ok");
assert_eq!(out, "fini");
// Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final.
assert_eq!(
*mediator.calls.lock().unwrap(),
vec!["alive", "alive", "alive", "idle"],
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
);
}
}