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:
@ -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