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>
339 lines
11 KiB
Rust
339 lines
11 KiB
Rust
//! Lot 1 (readiness/heartbeat model-agnostique) — tests unitaires QA **indépendants**
|
|
//! du helper applicatif `drain_with_readiness`, **100 % fakes**.
|
|
//!
|
|
//! Couvre les points 5 et 6 du périmètre QA :
|
|
//!
|
|
//! - **Point 5** : un flux se terminant par `Final` appelle `mark_idle(agent)`
|
|
//! **exactement une fois** ; les `Heartbeat`/deltas/activités intermédiaires
|
|
//! n'appellent **jamais** `mark_idle`.
|
|
//! - **Point 6 (le bug d'origine)** : un agent cible qui **ne fait que renvoyer un
|
|
//! `Final`** (sans jamais appeler `idea_reply`) débloque bien la file via
|
|
//! `mark_idle` — c'est exactement la cause racine du blocage `Busy`.
|
|
//!
|
|
//! On teste au niveau `drain_with_readiness` (le rendez-vous synchrone qu'`ask_agent`
|
|
//! utilise sur la session structurée d'une cible) avec :
|
|
//! - un fake `AgentSession` scriptable (calqué sur `send_blocking_d1.rs`) ;
|
|
//! - un fake `InputMediator` qui **compte** les `mark_idle` par agent et journalise
|
|
//! l'ordre des appels, sans seconde file ni I/O.
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use application::drain_with_readiness;
|
|
use domain::ids::AgentId;
|
|
use domain::input::{AgentBusyState, InputMediator};
|
|
use domain::mailbox::{PendingReply, Ticket};
|
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
|
use domain::SessionId;
|
|
use uuid::Uuid;
|
|
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum Script {
|
|
Stream(Vec<ReplyEvent>),
|
|
Err(AgentSessionError),
|
|
}
|
|
|
|
struct ScriptedSession {
|
|
id: SessionId,
|
|
script: Mutex<Option<Script>>,
|
|
shutdowns: AtomicUsize,
|
|
}
|
|
|
|
impl ScriptedSession {
|
|
fn new(script: Script) -> Self {
|
|
Self {
|
|
id: sid(1),
|
|
script: Mutex::new(Some(script)),
|
|
shutdowns: AtomicUsize::new(0),
|
|
}
|
|
}
|
|
fn shutdown_count(&self) -> usize {
|
|
self.shutdowns.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for ScriptedSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
fn conversation_id(&self) -> Option<String> {
|
|
None
|
|
}
|
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
let script = self
|
|
.script
|
|
.lock()
|
|
.unwrap()
|
|
.take()
|
|
.expect("send scripté une seule fois");
|
|
match script {
|
|
Script::Stream(events) => Ok(Box::new(events.into_iter())),
|
|
Script::Err(e) => Err(e),
|
|
}
|
|
}
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake InputMediator : compte les mark_idle par agent + journalise les appels.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct RecordingMediator {
|
|
/// (agent, "mark_idle") dans l'ordre des appels.
|
|
calls: Mutex<Vec<(AgentId, &'static str)>>,
|
|
}
|
|
|
|
impl RecordingMediator {
|
|
fn new() -> Self {
|
|
Self {
|
|
calls: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
fn mark_idle_count(&self, agent: AgentId) -> usize {
|
|
self.calls
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|(a, kind)| *a == agent && *kind == "mark_idle")
|
|
.count()
|
|
}
|
|
fn total_calls(&self) -> usize {
|
|
self.calls.lock().unwrap().len()
|
|
}
|
|
}
|
|
|
|
impl InputMediator for RecordingMediator {
|
|
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
|
// Jamais utilisé par drain_with_readiness ; un future qui ne résout pas.
|
|
PendingReply::new(Box::pin(std::future::pending()))
|
|
}
|
|
fn preempt(&self, agent: AgentId) {
|
|
self.calls.lock().unwrap().push((agent, "preempt"));
|
|
}
|
|
fn mark_idle(&self, agent: AgentId) {
|
|
self.calls.lock().unwrap().push((agent, "mark_idle"));
|
|
}
|
|
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
|
AgentBusyState::Idle
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers d'événements
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn delta(t: &str) -> ReplyEvent {
|
|
ReplyEvent::TextDelta { text: t.to_owned() }
|
|
}
|
|
fn tool(l: &str) -> ReplyEvent {
|
|
ReplyEvent::ToolActivity {
|
|
label: l.to_owned(),
|
|
}
|
|
}
|
|
fn heartbeat() -> ReplyEvent {
|
|
ReplyEvent::Heartbeat
|
|
}
|
|
fn final_(c: &str) -> ReplyEvent {
|
|
ReplyEvent::Final {
|
|
content: c.to_owned(),
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Point 6 (LE point qui compte) : un Final SEUL débloque la file via mark_idle,
|
|
// sans aucun idea_reply.
|
|
// ===========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn final_only_stream_unblocks_queue_via_mark_idle() {
|
|
// L'agent cible ne fait QUE renvoyer un Final (jamais d'idea_reply).
|
|
let agent = aid(42);
|
|
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await;
|
|
|
|
assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu");
|
|
assert_eq!(
|
|
mediator.mark_idle_count(agent),
|
|
1,
|
|
"le Final déterministe DOIT marquer l'agent Idle (fix de la cause racine du blocage Busy)"
|
|
);
|
|
// Aucun autre appel parasite, et la session reste vivante (pas de shutdown).
|
|
assert_eq!(mediator.total_calls(), 1);
|
|
assert_eq!(session.shutdown_count(), 0);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Point 5 : exactement UN mark_idle ; les events intermédiaires n'en émettent pas.
|
|
// ===========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn intermediate_events_do_not_mark_idle_only_final_does() {
|
|
let agent = aid(7);
|
|
// Heartbeats + deltas + activité PUIS le Final.
|
|
let session = ScriptedSession::new(Script::Stream(vec![
|
|
heartbeat(),
|
|
delta("hel"),
|
|
tool("lit un fichier"),
|
|
heartbeat(),
|
|
delta("lo"),
|
|
final_("hello"),
|
|
]));
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
|
|
|
assert_eq!(out, Ok("hello".to_owned()));
|
|
assert_eq!(
|
|
mediator.mark_idle_count(agent),
|
|
1,
|
|
"mark_idle exactement UNE fois (au Final), jamais sur heartbeat/delta/activité"
|
|
);
|
|
assert_eq!(
|
|
mediator.total_calls(),
|
|
1,
|
|
"aucun appel mediator hormis l'unique mark_idle"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heartbeats_alone_never_mark_idle_before_final() {
|
|
// Que des heartbeats avant le Final : aucun ne doit marquer Idle.
|
|
let agent = aid(9);
|
|
let session = ScriptedSession::new(Script::Stream(vec![
|
|
heartbeat(),
|
|
heartbeat(),
|
|
heartbeat(),
|
|
final_("fini"),
|
|
]));
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
|
assert_eq!(out, Ok("fini".to_owned()));
|
|
assert_eq!(mediator.mark_idle_count(agent), 1);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Bords : pas de Final ⇒ pas de mark_idle ; erreur de send propagée ; mauvais agent.
|
|
// ===========================================================================
|
|
|
|
#[tokio::test]
|
|
async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
|
let agent = aid(3);
|
|
let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")]));
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
|
assert!(
|
|
matches!(out, Err(AgentSessionError::Io(_))),
|
|
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
|
|
);
|
|
assert_eq!(
|
|
mediator.mark_idle_count(agent),
|
|
0,
|
|
"sans Final, on ne marque JAMAIS Idle (jamais de faux Idle)"
|
|
);
|
|
assert_eq!(mediator.total_calls(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_error_is_propagated_and_no_mark_idle() {
|
|
let agent = aid(5);
|
|
let session =
|
|
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
|
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
|
assert_eq!(mediator.mark_idle_count(agent), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_idle_targets_the_drained_agent_only() {
|
|
// Le mark_idle doit porter sur l'agent passé à drain_with_readiness, pas un autre.
|
|
let drained = aid(100);
|
|
let other = aid(200);
|
|
let session = ScriptedSession::new(Script::Stream(vec![final_("ok")]));
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await;
|
|
assert_eq!(mediator.mark_idle_count(drained), 1);
|
|
assert_eq!(
|
|
mediator.mark_idle_count(other),
|
|
0,
|
|
"mark_idle ne doit cibler que l'agent drainé"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Timeout (garde-fou du tour) : Timeout renvoyé, pas de mark_idle, session vivante.
|
|
// (Le helper borne via tokio::time::timeout ; on force l'expiration avec un Final
|
|
// reporté — réutilise un fake retardé minimal local.)
|
|
// ===========================================================================
|
|
|
|
struct DelayedSession {
|
|
delay: Duration,
|
|
shutdowns: AtomicUsize,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for DelayedSession {
|
|
fn id(&self) -> SessionId {
|
|
sid(2)
|
|
}
|
|
fn conversation_id(&self) -> Option<String> {
|
|
None
|
|
}
|
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
tokio::time::sleep(self.delay).await;
|
|
Ok(Box::new(vec![final_("too late")].into_iter()))
|
|
}
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
|
let agent = aid(11);
|
|
let session = DelayedSession {
|
|
delay: Duration::from_secs(1),
|
|
shutdowns: AtomicUsize::new(0),
|
|
};
|
|
let mediator = RecordingMediator::new();
|
|
|
|
let out =
|
|
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
|
assert_eq!(out, Err(AgentSessionError::Timeout));
|
|
assert_eq!(
|
|
mediator.mark_idle_count(agent),
|
|
0,
|
|
"un timeout ne marque pas Idle (le tour n'a pas rendu son Final)"
|
|
);
|
|
assert_eq!(
|
|
session.shutdowns.load(Ordering::SeqCst),
|
|
0,
|
|
"timeout ne tue PAS la session (§17.1)"
|
|
);
|
|
}
|