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 {