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

@ -47,10 +47,10 @@ pub struct ParsedLine {
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
///
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","cwd":…,"tools":…,…}`
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
/// événement émis.
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
/// ⇒ **ignoré** (comme tout `type` inconnu).
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
/// - `{"type":"assistant","message":{"role":"assistant","content":[
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
/// ], …},"session_id":"…","parent_tool_use_id":null}`
@ -81,7 +81,12 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
.map(str::to_owned);
let events = match value.get("type").and_then(Value::as_str) {
Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id.
// init/handshake : on capte le session_id ET on émet un battement de cœur
// (preuve de vivacité non terminale : la CLI a démarré et répond).
Some("system") => vec![ReplyEvent::Heartbeat],
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
Some("assistant") => assistant_events(&value),
Some("result") => value
.get("result")
@ -92,7 +97,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
}]
})
.unwrap_or_default(),
_ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré.
_ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse).
};
Ok(ParsedLine { events, session_id })
@ -216,13 +221,22 @@ impl AgentSession for ClaudeSdkSession {
let mut events = Vec::new();
let mut captured_id = None;
for line in &raw_lines {
'lines: for line in &raw_lines {
let parsed = parse_event(line)?;
if let Some(id) = parsed.session_id {
captured_id = Some(id);
}
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
events.extend(parsed.events);
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
// ne le suive dans le flux.
for event in parsed.events {
let is_final = matches!(event, ReplyEvent::Final { .. });
events.push(event);
if is_final {
break 'lines;
}
}
}
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
if let Some(id) = captured_id {

View File

@ -43,13 +43,13 @@ pub struct ParsedLine {
///
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
/// (= id de conversation pour la reprise), **aucun** événement émis.
/// - `{"type":"turn.started"}` ⇒ ignoré.
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de
/// l'`agent_message`).
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
/// vient de l'`agent_message`, pas de `turn.completed`).
///
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
@ -75,6 +75,10 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
.and_then(Value::as_str)
.map(str::to_owned);
}
// Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒
// battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient
// toujours de l'`agent_message`, jamais de `turn.completed`.
Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat),
Some("item.completed") => {
if let Some(item) = value.get("item") {
match item.get("type").and_then(Value::as_str) {
@ -94,7 +98,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
}
}
}
// turn.started / turn.completed / type inconnu : ignoré (robustesse).
// type inconnu : ignoré (robustesse).
_ => {}
}
@ -192,12 +196,21 @@ impl AgentSession for CodexExecSession {
let mut events = Vec::new();
let mut captured_id = None;
for line in &raw_lines {
'lines: for line in &raw_lines {
let parsed = parse_event(line)?;
if let Some(id) = parsed.conversation_id {
captured_id = Some(id);
}
events.extend(parsed.events);
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`turn.completed` postérieur)
// ne le suive dans le flux.
for event in parsed.events {
let is_final = matches!(event, ReplyEvent::Final { .. });
events.push(event);
if is_final {
break 'lines;
}
}
}
if let Some(id) = captured_id {
*self.conversation_id.lock().expect("mutex sain") = Some(id);

View File

@ -208,14 +208,17 @@ pub(crate) mod harness {
}
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
}
// Les événements avant le Final ne sont que des deltas / activités.
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
for e in &events[..events.len() - 1] {
assert!(
matches!(
e,
ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. }
ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. }
| ReplyEvent::Heartbeat
),
"avant le Final, seuls deltas/activités sont permis, vu: {e:?}"
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
);
}

View File

@ -106,23 +106,24 @@ mod tests {
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
#[test]
fn claude_parse_init_captures_session_id_without_event() {
fn claude_parse_init_captures_session_id_and_heartbeats() {
let parsed = claude::parse_event(
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
)
.expect("parse ok");
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
assert!(parsed.events.is_empty());
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
}
#[test]
fn claude_parse_rate_limit_event_is_ignored() {
fn claude_parse_rate_limit_event_is_heartbeat() {
let parsed = claude::parse_event(
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
)
.expect("parse ok");
// Type inconnu/non pertinent : ignoré (mais session_id tout de même capté).
assert!(parsed.events.is_empty());
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
}
@ -221,10 +222,15 @@ mod tests {
let sess =
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
// Le handshake ne capte que le thread_id, sans événement (pas un heartbeat).
assert!(sess.events.is_empty());
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
assert!(started.events.is_empty());
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
let completed =
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
let msg = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
@ -236,11 +242,6 @@ mod tests {
content: "fini".to_owned()
}]
);
let completed =
codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#)
.expect("ok");
assert!(completed.events.is_empty());
}
#[test]
@ -728,30 +729,26 @@ mod tests {
assert!(p.events.is_empty());
}
/// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur.
/// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont
/// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.)
#[test]
fn codex_empty_and_unknown_ignored() {
assert_eq!(codex::parse_event("").unwrap(), Default::default());
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
// Un `type` inconnu reste ignoré (robustesse), pas un heartbeat.
assert!(codex::parse_event(r#"{"type":"telemetry"}"#)
.unwrap()
.events
.is_empty());
assert!(codex::parse_event(r#"{"type":"turn.started"}"#)
.unwrap()
.events
.is_empty());
assert!(
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
.unwrap()
.events
.is_empty()
);
}
// ---- Machinerie process via FakeCli ---------------------------------
/// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert
/// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov).
/// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui
/// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un
/// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus
/// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut
/// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au
/// premier `Final`.
#[tokio::test]
async fn codex_stream_closed_after_final() {
let fake = FakeCli::printing(&[
@ -762,12 +759,19 @@ mod tests {
]);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let after = events
let finals = events
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(finals, 1, "exactement un Final");
// Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre.
let after_final_terminals = events
.iter()
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
.skip(1)
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(after, 0);
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
}
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
@ -1097,6 +1101,8 @@ mod tests {
assert_eq!(
events,
vec![
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
ReplyEvent::Heartbeat,
ReplyEvent::TextDelta { text: "a".into() },
ReplyEvent::ToolActivity { label: "T".into() },
ReplyEvent::TextDelta { text: "b".into() },
@ -1104,7 +1110,7 @@ mod tests {
content: "final-ok".into()
},
],
"le flot complet doit aplatir les 3 blocs PUIS un seul Final"
"heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final"
);
// Un seul Final, en dernière position (redondant mais explicite).
assert_eq!(