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:
@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user