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:
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
@ -0,0 +1,338 @@
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
@ -2825,3 +2825,257 @@ async fn p6b_failing_record_does_not_degrade_ask() {
|
||||
"no turns stored when append fails"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lot 1b — auto-lancement d'une cible **froide** structurée sur le chemin `ask`
|
||||
// ===========================================================================
|
||||
//
|
||||
// Avant le Lot 1b, `ask_agent` ne routait vers `ask_structured` que si la cible avait
|
||||
// **déjà** une session structurée vivante ; une cible structurée froide tombait dans
|
||||
// `ensure_live_pty` (chemin PTY) — or une cible structurée n'a pas de PTY, donc le tour
|
||||
// restait bloqué `Busy` (débloquable seulement par un `idea_reply` explicite). Le Lot 1b
|
||||
// démarre la session structurée via le **même** launcher que le chemin PTY (qui route
|
||||
// §17.4 vers `launch_structured` et l'insère dans le registre **partagé**), puis route
|
||||
// vers `ask_structured` : le `Final` déterministe de la session débloque le tour et
|
||||
// marque l'agent `Idle` (`mark_idle`), **sans** `idea_reply` ni PTY.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use application::StructuredSessions;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyEvent, ReplyStream,
|
||||
};
|
||||
|
||||
/// Session structurée fake : `send()` rend un unique [`ReplyEvent::Final`] qui **écho**
|
||||
/// le prompt reçu — c'est ce `Final` que `drain_with_readiness` transforme en réponse
|
||||
/// du tour **et** en `mark_idle`. Aucun `idea_reply` n'est nécessaire.
|
||||
struct EchoSession {
|
||||
id: SessionId,
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSession for EchoSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let stream: ReplyStream = Box::new(
|
||||
vec![ReplyEvent::Final {
|
||||
content: format!("structured: {prompt}"),
|
||||
}]
|
||||
.into_iter(),
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fabrique fake : `supports` = profil structuré, et `start` **compte** les démarrages
|
||||
/// (preuve qu'une session froide a bien été auto-lancée) en rendant une [`EchoSession`].
|
||||
#[derive(Clone, Default)]
|
||||
struct CountingFactory {
|
||||
starts: Arc<AtomicUsize>,
|
||||
next_id: Arc<Mutex<u128>>,
|
||||
}
|
||||
impl CountingFactory {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
next_id: Arc::new(Mutex::new(9000)),
|
||||
}
|
||||
}
|
||||
fn start_count(&self) -> usize {
|
||||
self.starts.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentSessionFactory for CountingFactory {
|
||||
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||
profile.structured_adapter.is_some()
|
||||
}
|
||||
async fn start(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
let id = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
let id = SessionId::from_uuid(Uuid::from_u128(*n));
|
||||
*n += 1;
|
||||
id
|
||||
};
|
||||
Ok(Arc::new(EchoSession { id }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tout le câblage `ask` **structuré** : launcher + service voient le **même** registre
|
||||
/// `StructuredSessions`, et le launcher porte la fabrique (routage §17.4).
|
||||
struct StructuredAskFixture {
|
||||
service: Arc<OrchestratorService>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
factory: CountingFactory,
|
||||
pty: FakePty,
|
||||
}
|
||||
|
||||
fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture {
|
||||
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let pty = FakePty::new(sid(777));
|
||||
let bus = SpyBus::default();
|
||||
let mailbox = Arc::new(TestMailbox::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let factory = CountingFactory::new();
|
||||
|
||||
let create = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(bus.clone()),
|
||||
));
|
||||
// Launcher câblé **structuré** : pour un profil à `structured_adapter`, `execute`
|
||||
// route §17.4 → `launch_structured`, démarre la session via la fabrique et l'insère
|
||||
// dans CE registre partagé (le même `Arc` que le service ci-dessous).
|
||||
let launch = Arc::new(
|
||||
LaunchAgent::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::new(FakeRuntime),
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_structured(
|
||||
Arc::new(factory.clone()) as Arc<dyn AgentSessionFactory>,
|
||||
Arc::clone(&structured),
|
||||
),
|
||||
);
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
Arc::new(SeqIds::new()),
|
||||
));
|
||||
let mediator = Arc::new(TestMediator::new(
|
||||
Arc::clone(&mailbox),
|
||||
Arc::new(pty.clone()) as Arc<dyn PtyPort>,
|
||||
));
|
||||
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
|
||||
|
||||
let service = Arc::new(
|
||||
OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::clone(&sessions),
|
||||
)
|
||||
.with_input_mediator(
|
||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_events(Arc::new(bus.clone()))
|
||||
// Même registre que le launcher : `ask_agent` y relit la session fraîchement
|
||||
// insérée par `launch_structured`.
|
||||
.with_structured(Arc::clone(&structured)),
|
||||
);
|
||||
|
||||
StructuredAskFixture {
|
||||
service,
|
||||
structured,
|
||||
factory,
|
||||
pty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cible **froide** structurée (adaptateur Claude, aucune session vivante) : `ask_agent`
|
||||
/// auto-lance sa session structurée via le launcher (factory.start appelé **une** fois),
|
||||
/// puis la draine — le `Final` débloque le round-trip **sans** `idea_reply` ni PTY.
|
||||
#[tokio::test]
|
||||
async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_none(),
|
||||
"cible structurée froide : aucune session avant l'ask"
|
||||
);
|
||||
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("ask ok");
|
||||
|
||||
// Le `Final` de la session a rendu le contenu — sans aucun `idea_reply` dispatché.
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("structured: Analyse §17"),
|
||||
"le Final de la session auto-lancée débloque le tour"
|
||||
);
|
||||
// Exactement **un** démarrage de session structurée (la cible froide a été lancée).
|
||||
assert_eq!(
|
||||
fx.factory.start_count(),
|
||||
1,
|
||||
"une session structurée a été auto-lancée pour la cible froide"
|
||||
);
|
||||
// La session est désormais vivante dans le registre partagé (insérée par le launcher).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
"la session auto-lancée est enregistrée dans StructuredSessions"
|
||||
);
|
||||
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal).
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"cible structurée : aucun PTY spawné (chemin chat, pas terminal)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude)
|
||||
/// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve
|
||||
/// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin).
|
||||
#[tokio::test]
|
||||
async fn ask_warm_structured_target_reuses_session_without_relaunch() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// 1er ask : auto-lance (start_count == 1).
|
||||
let _ = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("first ask ok");
|
||||
assert_eq!(fx.factory.start_count(), 1, "1er ask auto-lance la session");
|
||||
|
||||
// 2e ask : cible chaude ⇒ aucune relance.
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(&project(), cmd(ASK_JSON))
|
||||
.await
|
||||
.expect("second ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17"));
|
||||
assert_eq!(
|
||||
fx.factory.start_count(),
|
||||
1,
|
||||
"cible chaude : la session est réutilisée, aucune relance"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user