feat(agent): fondation exécution structurée des agents IA (D0+D1) — §17
Pivot orchestration : agents IA pilotés via leur mode programmatique/JSON
(capture déterministe), au lieu du TUI brut + self-report. §16 (idea/MCP)
marquée remplacée comme voie principale.
- D0 (domaine) : port AgentSession + AgentSessionFactory, types ReplyEvent
/ReplyStream/AgentSessionError, champ AgentProfile.structured_adapter
(Option<StructuredAdapter{Claude,Codex}>, skip si None ⇒ zéro régression),
catalogue Claude/Codex annotés.
- D1 (application) : registre StructuredSessions (jumeau de TerminalSessions),
agrégateur LiveSessions{pty,structured} derrière LiveAgentRegistry (vivant si
PTY OU structuré, surface du trait inchangée), helper send_blocking (draine le
ReplyStream jusqu'au Final, Timeout sans tuer la session).
Tests : domaine 16+2 ; application registre 11 + send_blocking 9 ; workspace 0 échec.
A/B intacts. Aucun adapter concret (D2), pas de Tauri/front.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,6 +14,9 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||
uuid = { workspace = true }
|
||||
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
|
||||
# Déjà le runtime async du workspace ; pas une nouvelle dépendance externe.
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
@ -56,7 +56,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("claude reference profile is valid"),
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude),
|
||||
AgentProfile::new(
|
||||
reference_id("codex"),
|
||||
"OpenAI Codex CLI",
|
||||
@ -68,7 +69,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("codex reference profile is valid"),
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex),
|
||||
AgentProfile::new(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
|
||||
@ -10,10 +10,13 @@ mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod resume;
|
||||
mod structured;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use structured::send_blocking;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use resume::{
|
||||
|
||||
73
crates/application/src/agent/structured.rs
Normal file
73
crates/application/src/agent/structured.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Helper applicatif `send_blocking` — le rendez-vous synchrone inter-agents
|
||||
//! au-dessus du port [`AgentSession`] (ARCHITECTURE §17.1 / §17.4).
|
||||
//!
|
||||
//! `AgentSession::send` retourne un **flux** d'événements (`ReplyStream`), à la
|
||||
//! manière de `PtyPort::subscribe_output`, mais **typé** : deltas de texte →
|
||||
//! activités d'outil → **un** événement terminal déterministe
|
||||
//! [`ReplyEvent::Final`]. Le rendez-vous synchrone dont l'orchestrateur a besoin
|
||||
//! (§17.4) s'obtient en **drainant ce flux jusqu'au `Final`** : c'est *la* primitive
|
||||
//! de la messagerie inter-agents, **sans outbox, sans corrélation fichier** (le
|
||||
//! `Final` *est* la fin de tour).
|
||||
//!
|
||||
//! DRY : un seul chemin de lecture (le flux). `send_blocking` n'est qu'un *consom-
|
||||
//! mateur* du même flux que la cellule chat utilise pour le rendu incrémental.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
///
|
||||
/// C'est le rendez-vous synchrone (§17.4) : on attend que le tour soit
|
||||
/// déterministiquement terminé (`Final`) avant de rendre la main. Les deltas de
|
||||
/// texte et les activités d'outil traversés en chemin sont **ignorés** ici (ils
|
||||
/// servent le rendu incrémental côté UI, pas l'appelant synchrone).
|
||||
///
|
||||
/// `timeout`, lorsqu'il est fourni, borne l'attente : si aucun `Final` n'est observé
|
||||
/// dans le délai, on retourne [`AgentSessionError::Timeout`] **sans tuer la
|
||||
/// session** (elle reste vivante dans le registre ; l'appelant décide de la suite).
|
||||
/// `None` ⇒ pas de borne temporelle (on attend la fin du tour).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
|
||||
/// (échec de communication / décodage de la sortie structurée) ;
|
||||
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
|
||||
/// interrompu) ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
|
||||
pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
|
||||
Ok(result) => result,
|
||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||
},
|
||||
None => drain_to_final(session, prompt).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**.
|
||||
///
|
||||
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
}
|
||||
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
}
|
||||
@ -38,7 +38,7 @@ pub use agent::{
|
||||
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
send_blocking, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use embedder::{
|
||||
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
|
||||
@ -94,8 +94,8 @@ pub use template::{
|
||||
UpdateTemplateOutput,
|
||||
};
|
||||
pub use terminal::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, OpenTerminal,
|
||||
OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions,
|
||||
WriteToTerminal, WriteToTerminalInput,
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
|
||||
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
|
||||
StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
mod registry;
|
||||
mod usecases;
|
||||
|
||||
pub use registry::{LiveAgentRegistry, TerminalSessions};
|
||||
pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
pub use usecases::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
//! application layer rather than the domain or the adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::ports::{AgentSession, PtyHandle};
|
||||
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
|
||||
|
||||
/// A registered, live terminal: its PTY handle plus the domain snapshot.
|
||||
@ -204,3 +204,254 @@ impl TerminalSessions {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StructuredSessions — le jumeau de TerminalSessions pour les sessions IA (§17.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Une session structurée enregistrée : la session vivante plus les coordonnées
|
||||
/// (agent + cellule hôte) que [`AgentSession`] ne porte pas lui-même.
|
||||
///
|
||||
/// `AgentSession` n'expose que `id()`/`conversation_id()` ; comme le snapshot
|
||||
/// [`TerminalSession`] côté PTY, on associe ici l'`agent_id` (clé de liveness) et
|
||||
/// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
|
||||
/// [`TerminalSessions`].
|
||||
struct StructuredEntry {
|
||||
/// La session vivante (ressource process/SDK), derrière le port domaine.
|
||||
session: Arc<dyn AgentSession>,
|
||||
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
|
||||
agent_id: AgentId,
|
||||
/// La cellule (feuille de layout) qui héberge actuellement la vue.
|
||||
node_id: NodeId,
|
||||
}
|
||||
|
||||
/// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5).
|
||||
///
|
||||
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
|
||||
/// du modèle métier — cf. les docs de [`TerminalSessions`]), même surface côté
|
||||
/// liveness/agent (`session_for_agent`, `node_for_agent`, `live_agents`,
|
||||
/// `rebind_agent_node`, `insert`/`remove`/`session`). La seule différence : il
|
||||
/// stocke des `Arc<dyn AgentSession>` (sessions programmatiques) au lieu de
|
||||
/// [`PtyHandle`]/[`TerminalSession`].
|
||||
///
|
||||
/// Respecte l'invariant produit **« 1 session vivante par agent »** : la garde
|
||||
/// d'unicité (généralisée sur les deux registres via [`LiveSessions`]) interroge
|
||||
/// `session_for_agent` avant tout lancement.
|
||||
#[derive(Default)]
|
||||
pub struct StructuredSessions {
|
||||
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for StructuredSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().any(|e| e.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredSessions {
|
||||
/// Crée un registre vide.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par
|
||||
/// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
|
||||
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
let id = session.id();
|
||||
map.insert(
|
||||
id,
|
||||
StructuredEntry {
|
||||
session,
|
||||
agent_id,
|
||||
node_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la session enregistrée pour un id, si présente.
|
||||
#[must_use]
|
||||
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
|
||||
}
|
||||
|
||||
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
|
||||
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| Arc::clone(&e.session))
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| e.session.id())
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::node_for_agent`].
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| e.node_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
|
||||
/// `(AgentId, NodeId, SessionId)` par session structurée vivante.
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| (e.agent_id, e.node_id, e.session.id()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
|
||||
/// redémarrer la conversation (« la cellule est une vue », §17.6).
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::rebind_agent_node`] : seul le `node_id`
|
||||
/// change ; la session, son id et sa conversation restent intacts. Retourne la
|
||||
/// session rebindée, ou `None` si l'agent n'a pas de session vivante.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|mut m| {
|
||||
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?;
|
||||
entry.node_id = node_id;
|
||||
Some(Arc::clone(&entry.session))
|
||||
})
|
||||
}
|
||||
|
||||
/// Retire une session du registre, retournant la session si présente (pour que
|
||||
/// l'appelant la `shutdown` hors du verrou).
|
||||
pub fn remove(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.remove(id).map(|e| e.session))
|
||||
}
|
||||
|
||||
/// Retourne toutes les sessions vivantes (pour un arrêt global propre au
|
||||
/// shutdown applicatif, jumeau de [`TerminalSessions::handles`]).
|
||||
#[must_use]
|
||||
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().map(|e| Arc::clone(&e.session)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Nombre de sessions structurées vivantes.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Si le registre est vide.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LiveSessions — agrégateur des deux registres derrière LiveAgentRegistry (§17.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Agrégateur de liveness sur **les deux** registres (PTY + structuré).
|
||||
///
|
||||
/// Un agent terminal brut vit dans [`TerminalSessions`] ; un agent IA structuré vit
|
||||
/// dans [`StructuredSessions`]. La garde d'unicité et l'orchestrateur (§17.4)
|
||||
/// dépendent de cet agrégateur (ISP : ils ne voient que la capacité « liveness +
|
||||
/// résolution »), et une requête de liveness/agent voit donc les deux registres :
|
||||
/// un agent est vivant s'il a une session vivante dans **l'un OU l'autre**.
|
||||
///
|
||||
/// Implémente [`LiveAgentRegistry`] : le trait existant n'est **pas modifié** (ses
|
||||
/// implémenteurs et appelants actuels — `TerminalSessions`, le snapshot — restent
|
||||
/// inchangés), il est simplement **réalisé par un troisième implémenteur** qui
|
||||
/// agrège, ce qui *généralise* sa portée à l'ensemble PTY+structuré sans régression.
|
||||
pub struct LiveSessions {
|
||||
/// Registre des sessions terminal brut (PTY).
|
||||
pub pty: Arc<TerminalSessions>,
|
||||
/// Registre des sessions IA structurées.
|
||||
pub structured: Arc<StructuredSessions>,
|
||||
}
|
||||
|
||||
impl LiveSessions {
|
||||
/// Construit l'agrégateur à partir des deux registres partagés.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<TerminalSessions>, structured: Arc<StructuredSessions>) -> Self {
|
||||
Self { pty, structured }
|
||||
}
|
||||
|
||||
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.pty
|
||||
.session_for_agent(agent_id)
|
||||
.or_else(|| self.structured.session_id_for_agent(agent_id))
|
||||
}
|
||||
|
||||
/// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.pty
|
||||
.node_for_agent(agent_id)
|
||||
.or_else(|| self.structured.node_for_agent(agent_id))
|
||||
}
|
||||
|
||||
/// Tous les agents vivants des deux registres (PTY puis structurés).
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
let mut all = self.pty.live_agents();
|
||||
all.extend(self.structured.live_agents());
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for LiveSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id)
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,3 +324,39 @@ fn catalogue_ids_are_stable_across_calls() {
|
||||
// And match the slug-derived id helper.
|
||||
assert_eq!(first[0].id, reference_profile_id("claude"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOT D0 (§17.3) — structured_adapter sur les profils de référence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn catalogue_claude_and_codex_carry_their_structured_adapter() {
|
||||
use domain::profile::StructuredAdapter;
|
||||
|
||||
let profiles = reference_profiles();
|
||||
let by_command: HashMap<&str, &AgentProfile> =
|
||||
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
|
||||
|
||||
// Claude / Codex sont pilotés en mode structuré (cellule chat + AgentSession).
|
||||
assert_eq!(
|
||||
by_command["claude"].structured_adapter,
|
||||
Some(StructuredAdapter::Claude),
|
||||
"Claude reference profile must declare the Claude structured adapter"
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["codex"].structured_adapter,
|
||||
Some(StructuredAdapter::Codex),
|
||||
"Codex reference profile must declare the Codex structured adapter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
|
||||
// §17.3 : les profils non encore couverts restent TUI/PTY (pas d'adapter).
|
||||
let profiles = reference_profiles();
|
||||
let by_command: HashMap<&str, &AgentProfile> =
|
||||
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
|
||||
|
||||
assert_eq!(by_command["gemini"].structured_adapter, None);
|
||||
assert_eq!(by_command["aider"].structured_adapter, None);
|
||||
}
|
||||
|
||||
222
crates/application/tests/send_blocking_d1.rs
Normal file
222
crates/application/tests/send_blocking_d1.rs
Normal file
@ -0,0 +1,222 @@
|
||||
//! LOT D1 (ARCHITECTURE §17.1 / §17.4 / §17.9 ligne D1) — tests unitaires du
|
||||
//! helper applicatif `send_blocking`, **100 % fakes**.
|
||||
//!
|
||||
//! `send_blocking` draine le flux d'un tour jusqu'au [`ReplyEvent::Final`] :
|
||||
//! - cas nominal : `TextDelta*` puis `Final{content}` ⇒ renvoie `content` (les
|
||||
//! deltas/activités traversés sont ignorés par le rendez-vous synchrone) ;
|
||||
//! - flux **sans** `Final` (épuisé) ⇒ [`AgentSessionError::Io`] ;
|
||||
//! - `send` renvoyant `Err(Decode/Io)` ⇒ propagé tel quel ;
|
||||
//! - **timeout** ⇒ [`AgentSessionError::Timeout`] **sans tuer la session**
|
||||
//! (aucun `shutdown` appelé) ; le fake retarde son `send` de façon
|
||||
//! déterministe (au-delà du `timeout`) pour forcer l'expiration.
|
||||
//!
|
||||
//! Fake `AgentSession` local et **scriptable** (inspiré du fake D0 du domaine) :
|
||||
//! il produit le `ReplyStream` qu'on lui a scripté, ou une erreur, ou un délai ;
|
||||
//! il compte ses `shutdown` pour prouver la non-mise-à-mort sur timeout.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::send_blocking;
|
||||
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))
|
||||
}
|
||||
|
||||
/// Ce que le fake doit faire au prochain `send`.
|
||||
enum Script {
|
||||
/// Renvoyer ce flux d'événements (consommé tel quel).
|
||||
Stream(Vec<ReplyEvent>),
|
||||
/// `send` échoue avec cette erreur.
|
||||
Err(AgentSessionError),
|
||||
/// `send` dort `delay` avant de renvoyer le flux (force le timeout).
|
||||
Delayed {
|
||||
delay: Duration,
|
||||
events: Vec<ReplyEvent>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Fake scriptable d'`AgentSession`. Mono-usage (un `send` scripté).
|
||||
struct ScriptedSession {
|
||||
id: SessionId,
|
||||
script: std::sync::Mutex<Option<Script>>,
|
||||
shutdowns: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ScriptedSession {
|
||||
fn new(script: Script) -> Self {
|
||||
Self {
|
||||
id: sid(1),
|
||||
script: std::sync::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) => {
|
||||
let stream: ReplyStream = Box::new(events.into_iter());
|
||||
Ok(stream)
|
||||
}
|
||||
Script::Err(e) => Err(e),
|
||||
Script::Delayed { delay, events } => {
|
||||
tokio::time::sleep(delay).await;
|
||||
let stream: ReplyStream = Box::new(events.into_iter());
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity { label: l.to_owned() }
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final { content: c.to_owned() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cas nominal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_final_content_ignoring_deltas_and_tools() {
|
||||
let session = ScriptedSession::new(Script::Stream(vec![
|
||||
delta("hel"),
|
||||
tool("reads a file"),
|
||||
delta("lo"),
|
||||
final_("hello world"),
|
||||
]));
|
||||
|
||||
let out = send_blocking(&session, "ping", None).await;
|
||||
assert_eq!(out, Ok("hello world".to_owned()));
|
||||
// Rendez-vous synchrone : on ne tue pas la session sur succès.
|
||||
assert_eq!(session.shutdown_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_final_content_with_no_intermediate_events() {
|
||||
// Flux = juste le Final déterministe.
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
|
||||
let out = send_blocking(&session, "x", Some(Duration::from_secs(5))).await;
|
||||
assert_eq!(out, Ok("done".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stops_at_first_final_even_if_more_events_follow() {
|
||||
// Robustesse : le drain s'arrête au PREMIER Final et renvoie son contenu.
|
||||
let session = ScriptedSession::new(Script::Stream(vec![
|
||||
delta("a"),
|
||||
final_("first"),
|
||||
final_("second-should-be-ignored"),
|
||||
]));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Ok("first".to_owned()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bord : flux sans Final
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_without_final_is_io_error() {
|
||||
let session = ScriptedSession::new(Script::Stream(vec![delta("a"), tool("b")]));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert!(
|
||||
matches!(out, Err(AgentSessionError::Io(_))),
|
||||
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_stream_is_io_error() {
|
||||
let session = ScriptedSession::new(Script::Stream(vec![]));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert!(
|
||||
matches!(out, Err(AgentSessionError::Io(_))),
|
||||
"flux vide ⇒ Io, obtenu {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bord : erreur de `send` propagée
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_decode_error_is_propagated() {
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_io_error_is_propagated() {
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Io("broken pipe".to_owned())));
|
||||
let out = send_blocking(&session, "x", Some(Duration::from_secs(5))).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Io("broken pipe".to_owned())));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bord : timeout — Timeout renvoyé ET session NON tuée
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_returns_timeout_error_and_does_not_kill_session() {
|
||||
// `send` dort 1 s ; timeout fixé à 20 ms ⇒ l'attente expire avant le Final.
|
||||
let session = ScriptedSession::new(Script::Delayed {
|
||||
delay: Duration::from_secs(1),
|
||||
events: vec![final_("too late")],
|
||||
});
|
||||
|
||||
let out = send_blocking(&session, "x", Some(Duration::from_millis(20))).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||
// §17.1 : on ne `shutdown` rien sur timeout — la session reste vivante.
|
||||
assert_eq!(
|
||||
session.shutdown_count(),
|
||||
0,
|
||||
"timeout ne doit PAS tuer la session"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_timeout_bound_waits_for_final() {
|
||||
// `timeout = None` ⇒ pas de borne : on attend le Final même après un délai.
|
||||
let session = ScriptedSession::new(Script::Delayed {
|
||||
delay: Duration::from_millis(10),
|
||||
events: vec![final_("eventually")],
|
||||
});
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Ok("eventually".to_owned()));
|
||||
}
|
||||
305
crates/application/tests/structured_registry_d1.rs
Normal file
305
crates/application/tests/structured_registry_d1.rs
Normal file
@ -0,0 +1,305 @@
|
||||
//! LOT D1 (ARCHITECTURE §17.5 / §17.9 ligne D1) — tests unitaires des registres
|
||||
//! structurés et de l'agrégateur de liveness, **100 % fakes**.
|
||||
//!
|
||||
//! Couvre :
|
||||
//! - [`StructuredSessions`] : `insert` + résolution `session_for_agent` /
|
||||
//! `session_id_for_agent` / `node_for_agent` / `session` (`None` si inconnu) ;
|
||||
//! invariant « 1 session vivante par agent » ; `rebind_agent_node` (change la
|
||||
//! cellule, pas la session) ; `remove` (retire + renvoie la session) ;
|
||||
//! `live_agents` (triplets `(AgentId, NodeId, SessionId)`) ; impl
|
||||
//! [`LiveAgentRegistry`] (`is_agent_live` / `is_node_live`).
|
||||
//! - [`LiveSessions`] (agrégateur) : OR des deux registres pour `is_agent_live` /
|
||||
//! `is_node_live` ; `live_agents` agrège (PTY puis structuré) ;
|
||||
//! `node_for_agent` / `session_id_for_agent` trouvent dans l'un ou l'autre.
|
||||
//!
|
||||
//! Style calqué sur `terminal_usecases.rs` (constructeurs validants du domaine,
|
||||
//! pas de littéraux fragiles). Le fake `AgentSession` est local et minimal :
|
||||
//! ces tests n'exercent QUE le registre, pas `send`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, PtyHandle, ReplyStream,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Fake minimal d'`AgentSession` : porte juste l'id (ce que le registre clé).
|
||||
/// `send`/`shutdown` ne sont pas exercés ici.
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
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(std::iter::empty());
|
||||
Ok(stream)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn fake(id: SessionId) -> Arc<dyn AgentSession> {
|
||||
Arc::new(FakeSession { id })
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// StructuredSessions
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn structured_insert_resolve_and_remove() {
|
||||
let reg = StructuredSessions::new();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
|
||||
let s = sid(1);
|
||||
let a = aid(10);
|
||||
let n = nid(100);
|
||||
reg.insert(fake(s), a, n);
|
||||
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert!(!reg.is_empty());
|
||||
|
||||
// Résolution par id de session.
|
||||
assert!(reg.session(&s).is_some());
|
||||
assert_eq!(reg.session(&s).unwrap().id(), s);
|
||||
|
||||
// Résolution par agent.
|
||||
assert_eq!(reg.session_id_for_agent(&a), Some(s));
|
||||
assert_eq!(reg.node_for_agent(&a), Some(n));
|
||||
assert!(reg.session_for_agent(&a).is_some());
|
||||
assert_eq!(reg.session_for_agent(&a).unwrap().id(), s);
|
||||
|
||||
// Inconnu ⇒ None partout.
|
||||
assert!(reg.session(&sid(999)).is_none());
|
||||
assert!(reg.session_for_agent(&aid(999)).is_none());
|
||||
assert!(reg.session_id_for_agent(&aid(999)).is_none());
|
||||
assert!(reg.node_for_agent(&aid(999)).is_none());
|
||||
|
||||
// remove retire ET renvoie la session.
|
||||
let removed = reg.remove(&s).expect("remove returns the session");
|
||||
assert_eq!(removed.id(), s);
|
||||
assert!(reg.is_empty());
|
||||
assert!(reg.remove(&s).is_none(), "second remove is a no-op");
|
||||
assert!(reg.session_for_agent(&a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_live_agents_lists_triples() {
|
||||
let reg = StructuredSessions::new();
|
||||
reg.insert(fake(sid(1)), aid(10), nid(100));
|
||||
reg.insert(fake(sid(2)), aid(20), nid(200));
|
||||
|
||||
let mut live = reg.live_agents();
|
||||
live.sort_by_key(|(a, _, _)| a.as_uuid());
|
||||
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![
|
||||
(aid(10), nid(100), sid(1)),
|
||||
(aid(20), nid(200), sid(2)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_one_live_session_per_agent_invariant() {
|
||||
// L'agent n'a pas de session vivante avant insertion.
|
||||
let reg = StructuredSessions::new();
|
||||
let a = aid(10);
|
||||
assert!(!reg.is_agent_live(&a));
|
||||
assert!(reg.session_for_agent(&a).is_none());
|
||||
|
||||
reg.insert(fake(sid(1)), a, nid(100));
|
||||
assert!(reg.is_agent_live(&a));
|
||||
|
||||
// `session_for_agent` est non ambigu : il rend LA session de l'agent.
|
||||
let resolved = reg.session_for_agent(&a).unwrap().id();
|
||||
assert_eq!(resolved, sid(1));
|
||||
// Et un seul triplet figure pour cet agent dans live_agents.
|
||||
let count = reg.live_agents().iter().filter(|(x, _, _)| *x == a).count();
|
||||
assert_eq!(count, 1, "one live session per agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_rebind_changes_cell_not_session() {
|
||||
let reg = StructuredSessions::new();
|
||||
let a = aid(10);
|
||||
reg.insert(fake(sid(1)), a, nid(100));
|
||||
|
||||
let new_cell = nid(200);
|
||||
let rebound = reg
|
||||
.rebind_agent_node(&a, new_cell)
|
||||
.expect("rebind returns the session");
|
||||
|
||||
// Session inchangée (même id), cellule mise à jour.
|
||||
assert_eq!(rebound.id(), sid(1));
|
||||
assert_eq!(reg.node_for_agent(&a), Some(new_cell));
|
||||
assert_eq!(reg.session_id_for_agent(&a), Some(sid(1)));
|
||||
// Toujours une seule entrée (pas de duplication).
|
||||
assert_eq!(reg.len(), 1);
|
||||
|
||||
// Rebind d'un agent inconnu ⇒ None.
|
||||
assert!(reg.rebind_agent_node(&aid(999), nid(300)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_live_agent_registry_impl() {
|
||||
let reg = StructuredSessions::new();
|
||||
let a = aid(10);
|
||||
let n = nid(100);
|
||||
|
||||
assert!(!reg.is_agent_live(&a));
|
||||
assert!(!reg.is_node_live(&n));
|
||||
|
||||
reg.insert(fake(sid(1)), a, n);
|
||||
|
||||
assert!(reg.is_agent_live(&a));
|
||||
assert!(reg.is_node_live(&n));
|
||||
assert!(!reg.is_agent_live(&aid(999)));
|
||||
assert!(!reg.is_node_live(&nid(999)));
|
||||
|
||||
// is_node_live suit le rebind (la cellule vivante change).
|
||||
let _ = reg.rebind_agent_node(&a, nid(200));
|
||||
assert!(!reg.is_node_live(&n), "old cell no longer live");
|
||||
assert!(reg.is_node_live(&nid(200)), "new cell is live");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_sessions_snapshot_for_global_shutdown() {
|
||||
let reg = StructuredSessions::new();
|
||||
reg.insert(fake(sid(1)), aid(10), nid(100));
|
||||
reg.insert(fake(sid(2)), aid(20), nid(200));
|
||||
|
||||
let mut ids: Vec<SessionId> = reg.sessions().iter().map(|s| s.id()).collect();
|
||||
ids.sort_by_key(|s| s.as_uuid());
|
||||
assert_eq!(ids, vec![sid(1), sid(2)]);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LiveSessions — agrégateur (PTY OR structuré)
|
||||
// ===========================================================================
|
||||
|
||||
/// Insère un agent PTY dans `TerminalSessions`.
|
||||
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
|
||||
let session = TerminalSession::starting(
|
||||
s,
|
||||
n,
|
||||
ProjectPath::new("/p").unwrap(),
|
||||
SessionKind::Agent { agent_id: a },
|
||||
PtySize::new(24, 80).unwrap(),
|
||||
);
|
||||
pty.insert(PtyHandle { session_id: s }, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_agent_live_via_structured_only() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
|
||||
|
||||
let a = aid(10);
|
||||
structured.insert(fake(sid(1)), a, nid(100));
|
||||
|
||||
assert!(agg.is_agent_live(&a), "live in structured ⇒ true");
|
||||
assert!(agg.is_node_live(&nid(100)));
|
||||
assert_eq!(agg.session_id_for_agent(&a), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(&a), Some(nid(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_agent_live_via_pty_only() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
|
||||
|
||||
let a = aid(20);
|
||||
insert_pty(&pty, sid(2), a, nid(200));
|
||||
|
||||
assert!(agg.is_agent_live(&a), "live in PTY ⇒ true");
|
||||
assert!(agg.is_node_live(&nid(200)));
|
||||
assert_eq!(agg.session_id_for_agent(&a), Some(sid(2)));
|
||||
assert_eq!(agg.node_for_agent(&a), Some(nid(200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_agent_absent_from_both_is_not_live() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let agg = LiveSessions::new(pty, structured);
|
||||
|
||||
let a = aid(30);
|
||||
assert!(!agg.is_agent_live(&a), "absent from both ⇒ false");
|
||||
assert!(!agg.is_node_live(&nid(300)));
|
||||
assert!(agg.session_id_for_agent(&a).is_none());
|
||||
assert!(agg.node_for_agent(&a).is_none());
|
||||
assert!(agg.live_agents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_live_agents_concatenates_pty_then_structured() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
|
||||
|
||||
insert_pty(&pty, sid(1), aid(10), nid(100));
|
||||
structured.insert(fake(sid(2)), aid(20), nid(200));
|
||||
|
||||
let all = agg.live_agents();
|
||||
assert_eq!(all.len(), 2, "both registries contribute");
|
||||
// PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur).
|
||||
assert_eq!(all[0], (aid(10), nid(100), sid(1)));
|
||||
assert_eq!(all[1], (aid(20), nid(200), sid(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_resolution_prefers_pty_then_falls_back_to_structured() {
|
||||
// Deux agents distincts, un par registre : la résolution trouve chacun
|
||||
// dans le bon registre (OR / fallback).
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
|
||||
|
||||
let pty_agent = aid(10);
|
||||
let struct_agent = aid(20);
|
||||
insert_pty(&pty, sid(1), pty_agent, nid(100));
|
||||
structured.insert(fake(sid(2)), struct_agent, nid(200));
|
||||
|
||||
// Agent PTY : résolu par le registre PTY.
|
||||
assert_eq!(agg.session_id_for_agent(&pty_agent), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(&pty_agent), Some(nid(100)));
|
||||
// Agent structuré : fallback sur le registre structuré.
|
||||
assert_eq!(agg.session_id_for_agent(&struct_agent), Some(sid(2)));
|
||||
assert_eq!(agg.node_for_agent(&struct_agent), Some(nid(200)));
|
||||
|
||||
// is_node_live : OR sur les deux.
|
||||
assert!(agg.is_node_live(&nid(100)));
|
||||
assert!(agg.is_node_live(&nid(200)));
|
||||
assert!(!agg.is_node_live(&nid(999)));
|
||||
}
|
||||
@ -14,3 +14,4 @@ async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@ -189,6 +189,41 @@ pub type OutputStream = Box<dyn Iterator<Item = Vec<u8>> + Send>;
|
||||
/// A boxed stream of domain events, returned by [`EventBus::subscribe`].
|
||||
pub type EventStream = Box<dyn Iterator<Item = DomainEvent> + Send>;
|
||||
|
||||
/// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1).
|
||||
///
|
||||
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
|
||||
/// vers ces variantes ; **aucun** détail propre à une CLI (pas de `stream-json`,
|
||||
/// pas de `--output-format`, pas de chemin de transcript) ne franchit cette
|
||||
/// frontière domaine.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReplyEvent {
|
||||
/// Un fragment de texte assistant (rendu incrémental côté UI chat).
|
||||
TextDelta {
|
||||
/// Le fragment de texte.
|
||||
text: String,
|
||||
},
|
||||
/// Une activité d'outil de l'agent (best-effort, pour l'observabilité chat :
|
||||
/// « lit un fichier », « lance une commande »). Le `label` est déjà
|
||||
/// humain-lisible ; le détail brut reste dans l'adapter.
|
||||
ToolActivity {
|
||||
/// Libellé humain-lisible de l'activité.
|
||||
label: String,
|
||||
},
|
||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||
Final {
|
||||
/// Le contenu final agrégé du tour.
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
|
||||
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
|
||||
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
|
||||
/// plutôt que des octets bruts.
|
||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-port error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -218,6 +253,29 @@ pub enum PtyError {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Errors from an [`AgentSession`] / [`AgentSessionFactory`] (ARCHITECTURE §17.1).
|
||||
///
|
||||
/// Frontière nette : on ne propage **jamais** le JSON brut d'une CLI à travers
|
||||
/// ces erreurs (cf. [`AgentSessionError::Decode`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum AgentSessionError {
|
||||
/// La session programmatique n'a pas pu démarrer (CLI introuvable, mode
|
||||
/// structuré indisponible, handshake invalide).
|
||||
#[error("agent session start failed: {0}")]
|
||||
Start(String),
|
||||
/// Échec d'envoi/de communication avec la session vivante.
|
||||
#[error("agent session io failed: {0}")]
|
||||
Io(String),
|
||||
/// La sortie structurée de la CLI n'a pas pu être décodée (JSON cassé, schéma
|
||||
/// inattendu). On ne propage jamais le JSON brut.
|
||||
#[error("agent session decode failed: {0}")]
|
||||
Decode(String),
|
||||
/// `send_blocking` n'a pas observé de [`ReplyEvent::Final`] dans le temps
|
||||
/// imparti. La session **reste vivante** (on ne tue rien) ; l'appelant décide.
|
||||
#[error("agent session reply timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Errors from [`ProcessSpawner`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ProcessError {
|
||||
@ -402,6 +460,71 @@ pub trait AgentRuntime: Send + Sync {
|
||||
) -> Result<SpawnSpec, RuntimeError>;
|
||||
}
|
||||
|
||||
/// Une **session programmatique persistante** avec un agent IA (ARCHITECTURE §17.1) :
|
||||
/// une conversation vivante que l'on pilote en mode structuré et dont on lit la
|
||||
/// réponse de façon déterministe. Une instance ⇔ un agent IA (invariant « 1 session
|
||||
/// vivante/agent », porté au niveau *type*).
|
||||
///
|
||||
/// Hexagonal : ce trait est **domaine** ; les adapters Claude/Codex (infra) ne
|
||||
/// fuient aucun détail de CLI à travers lui. Substituable (Liskov) : Claude et
|
||||
/// Codex offrent les mêmes garanties (flux d'événements → [`ReplyEvent::Final`]
|
||||
/// déterministe), seul le moteur diffère.
|
||||
///
|
||||
/// Calqué sur [`PtyPort`] (consommé comme trait-objet `Arc<dyn AgentSession>`,
|
||||
/// d'où `#[async_trait]` pour rester object-safe — cf. note d'en-tête du module).
|
||||
#[async_trait]
|
||||
pub trait AgentSession: Send + Sync {
|
||||
/// L'id de session IdeA (mappe la cellule/agent, comme un [`PtyHandle::session_id`]).
|
||||
fn id(&self) -> SessionId;
|
||||
|
||||
/// L'id de conversation **du moteur** (opaque), persisté sur la cellule pour la
|
||||
/// reprise (§15.2). `None` tant que le moteur n'en a pas attribué. Permet à
|
||||
/// `LeafCell.conversation_id` de rester le pivot de reprise, model-agnostic.
|
||||
fn conversation_id(&self) -> Option<String>;
|
||||
|
||||
/// Transmet `prompt` à la session vivante et retourne le **flux** d'événements
|
||||
/// du tour (deltas → [`ReplyEvent::Final`]). Rendu incrémental (UI chat) ET
|
||||
/// base du rendez-vous synchrone (helper applicatif `send_blocking`).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] sur échec de
|
||||
/// communication/décodage.
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError>;
|
||||
|
||||
/// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AgentSessionError::Io`] si l'arrêt échoue.
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError>;
|
||||
}
|
||||
|
||||
/// **Factory** sélectionnée par le profil (ARCHITECTURE §17.1) : crée/reprend une
|
||||
/// [`AgentSession`] pour un agent IA. C'est elle qui sait *quel adapter* instancier
|
||||
/// (Claude/Codex) selon `profile.structured_adapter` (§17.3). Open/Closed : ajouter
|
||||
/// un moteur structuré = ajouter un adapter + une variante de registre, sans
|
||||
/// toucher au cœur.
|
||||
#[async_trait]
|
||||
pub trait AgentSessionFactory: Send + Sync {
|
||||
/// Vrai si cette factory sait piloter `profile` en mode structuré (sert au menu
|
||||
/// de sélection §17.6 : ne proposer que les profils supportés).
|
||||
fn supports(&self, profile: &AgentProfile) -> bool;
|
||||
|
||||
/// Démarre une session structurée pour `profile` dans `cwd` (run dir isolé
|
||||
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
|
||||
/// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
||||
/// structuré ne peut s'initialiser.
|
||||
async fn start(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||
}
|
||||
|
||||
/// Open and drive pseudo-terminals.
|
||||
#[async_trait]
|
||||
pub trait PtyPort: Send + Sync {
|
||||
|
||||
@ -118,6 +118,22 @@ impl SessionStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||
///
|
||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||
/// adapter le pilote en mode programmatique. Ajouter un moteur structuré = une
|
||||
/// variante ici + un adapter infra (`infrastructure/src/session/`), sans toucher
|
||||
/// au cœur. Un profil **sans** `structured_adapter` reste un profil **TUI/PTY**
|
||||
/// (terminal brut, comportement historique).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StructuredAdapter {
|
||||
/// Piloté par `ClaudeSdkSession` (mode `-p --output-format stream-json` / SDK).
|
||||
Claude,
|
||||
/// Piloté par `CodexExecSession` (`codex exec` structuré).
|
||||
Codex,
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
@ -148,6 +164,15 @@ pub struct AgentProfile {
|
||||
/// keeps today's behaviour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session: Option<SessionStrategy>,
|
||||
/// Adapter d'exécution **structurée** (ARCHITECTURE §17). `None` ⇒ agent
|
||||
/// **TUI/PTY** (cellule terminal brut, comportement historique). `Some(_)` ⇒
|
||||
/// agent **IA structuré** (cellule chat + port [`crate::ports::AgentSession`]).
|
||||
/// Open/Closed : ajouter un moteur = une variante + un adapter.
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
|
||||
/// sérialisation : un profil sans adapter sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub structured_adapter: Option<StructuredAdapter>,
|
||||
}
|
||||
|
||||
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
|
||||
@ -281,6 +306,16 @@ impl AgentProfile {
|
||||
detect,
|
||||
cwd_template,
|
||||
session,
|
||||
structured_adapter: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builder : fixe l'[`StructuredAdapter`] d'exécution structurée (§17) et
|
||||
/// renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
|
||||
/// d'appel) : les profils TUI/PTY ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub fn with_structured_adapter(mut self, adapter: StructuredAdapter) -> Self {
|
||||
self.structured_adapter = Some(adapter);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
396
crates/domain/tests/structured_session_d0.rs
Normal file
396
crates/domain/tests/structured_session_d0.rs
Normal file
@ -0,0 +1,396 @@
|
||||
//! LOT D0 (fondation §17.1) — tests purs du domaine :
|
||||
//! - `StructuredAdapter` : round-trip serde camelCase (`"claude"` / `"codex"`) ;
|
||||
//! - `AgentProfile.structured_adapter` : zéro régression serde (omission via
|
||||
//! `skip_serializing_if`, défaut `None` à la désérialisation), round-trip avec
|
||||
//! adapter, et builder `with_structured_adapter` qui ne touche rien d'autre ;
|
||||
//! - types du port `AgentSession` : `ReplyEvent` (3 variantes + égalité),
|
||||
//! `AgentSessionError` (variantes + `Display`/`Error`), et un **fake in-module**
|
||||
//! prouvant que `AgentSession`/`AgentSessionFactory` sont implémentables (D0 = pas
|
||||
//! d'impl réelle, seulement la conformité de signature).
|
||||
//!
|
||||
//! Style calqué sur `serde_roundtrip.rs` / `agent_profile_a0.rs` (réutilise les
|
||||
//! constructeurs validants du domaine, pas de littéraux fragiles).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ids::{ProfileId, SessionId};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, ReplyEvent, ReplyStream,
|
||||
SessionPlan,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::MarkdownDoc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn profid(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn roundtrip<T>(value: &T) -> T
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||
{
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
serde_json::from_str(&json).expect("deserialize")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StructuredAdapter — round-trip serde camelCase ("claude" / "codex")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn structured_adapter_serialises_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StructuredAdapter::Claude).unwrap(),
|
||||
"\"claude\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StructuredAdapter::Codex).unwrap(),
|
||||
"\"codex\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_adapter_deserialises_from_camel_case() {
|
||||
let claude: StructuredAdapter = serde_json::from_str("\"claude\"").unwrap();
|
||||
let codex: StructuredAdapter = serde_json::from_str("\"codex\"").unwrap();
|
||||
assert_eq!(claude, StructuredAdapter::Claude);
|
||||
assert_eq!(codex, StructuredAdapter::Codex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_adapter_roundtrips_both_variants() {
|
||||
for adapter in [StructuredAdapter::Claude, StructuredAdapter::Codex] {
|
||||
assert_eq!(roundtrip(&adapter), adapter);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_adapter_rejects_unknown_variant() {
|
||||
// Un JSON hors vocabulaire ne doit pas se faufiler (pas de défaut silencieux).
|
||||
let parsed: Result<StructuredAdapter, _> = serde_json::from_str("\"gemini\"");
|
||||
assert!(parsed.is_err(), "unknown adapter must fail to deserialise");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentProfile.structured_adapter — zéro régression serde
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Profil minimal TUI/PTY (sans adapter), construit via le constructeur validant.
|
||||
fn pty_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
profid(1),
|
||||
"Gemini CLI",
|
||||
"gemini",
|
||||
vec![],
|
||||
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
||||
Some("gemini --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_adapter_omits_the_field() {
|
||||
// skip_serializing_if = Option::is_none ⇒ la clé `structuredAdapter` est ABSENTE.
|
||||
let p = pty_profile();
|
||||
assert_eq!(p.structured_adapter, None);
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(
|
||||
!json.contains("structuredAdapter"),
|
||||
"field must be omitted when None; json was {json}"
|
||||
);
|
||||
// Et le round-trip reste fidèle.
|
||||
assert_eq!(roundtrip(&p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_profile_without_adapter_deserialises_to_none() {
|
||||
// Un `profiles.json` produit AVANT que le champ existe : pas de clé
|
||||
// `structuredAdapter` ⇒ défaut `None` (zéro régression de désérialisation).
|
||||
let json = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"name": "Gemini CLI",
|
||||
"command": "gemini",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "GEMINI.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let p: AgentProfile = serde_json::from_str(json).expect("legacy profile must deserialise");
|
||||
assert_eq!(p.structured_adapter, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_with_adapter_roundtrips_and_uses_camel_case() {
|
||||
let p = pty_profile().with_structured_adapter(StructuredAdapter::Claude);
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
// Clé camelCase + valeur camelCase de la variante.
|
||||
assert!(
|
||||
json.contains("\"structuredAdapter\":\"claude\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(!json.contains("structured_adapter"), "json was {json}");
|
||||
assert_eq!(roundtrip(&p), p);
|
||||
|
||||
// Codex aussi.
|
||||
let c = pty_profile().with_structured_adapter(StructuredAdapter::Codex);
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
assert!(
|
||||
json.contains("\"structuredAdapter\":\"codex\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert_eq!(roundtrip(&c), c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_structured_adapter_sets_only_that_field() {
|
||||
let before = pty_profile();
|
||||
let after = before.clone().with_structured_adapter(StructuredAdapter::Codex);
|
||||
|
||||
// Le seul champ muté :
|
||||
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
|
||||
assert_ne!(after.structured_adapter, before.structured_adapter);
|
||||
|
||||
// Tous les autres champs strictement inchangés :
|
||||
assert_eq!(after.id, before.id);
|
||||
assert_eq!(after.name, before.name);
|
||||
assert_eq!(after.command, before.command);
|
||||
assert_eq!(after.args, before.args);
|
||||
assert_eq!(after.context_injection, before.context_injection);
|
||||
assert_eq!(after.detect, before.detect);
|
||||
assert_eq!(after.cwd_template, before.cwd_template);
|
||||
assert_eq!(after.session, before.session);
|
||||
|
||||
// Preuve d'équivalence : ne diffère du `before` que par l'adapter posé.
|
||||
let mut patched = before;
|
||||
patched.structured_adapter = Some(StructuredAdapter::Codex);
|
||||
assert_eq!(after, patched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_structured_adapter_is_last_write_wins() {
|
||||
// Re-poser un adapter écrase le précédent (idempotent par valeur).
|
||||
let p = pty_profile()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert_eq!(p.structured_adapter, Some(StructuredAdapter::Codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_defaults_structured_adapter_to_none() {
|
||||
// Le constructeur `new` reste stable : il ne pose jamais d'adapter.
|
||||
assert_eq!(pty_profile().structured_adapter, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReplyEvent — 3 variantes construites, égalité, clone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn reply_event_three_variants_construct_and_carry_payload() {
|
||||
let delta = ReplyEvent::TextDelta {
|
||||
text: "hel".to_owned(),
|
||||
};
|
||||
let tool = ReplyEvent::ToolActivity {
|
||||
label: "reads a file".to_owned(),
|
||||
};
|
||||
let final_ = ReplyEvent::Final {
|
||||
content: "hello world".to_owned(),
|
||||
};
|
||||
|
||||
match &delta {
|
||||
ReplyEvent::TextDelta { text } => assert_eq!(text, "hel"),
|
||||
other => panic!("expected TextDelta, got {other:?}"),
|
||||
}
|
||||
match &tool {
|
||||
ReplyEvent::ToolActivity { label } => assert_eq!(label, "reads a file"),
|
||||
other => panic!("expected ToolActivity, got {other:?}"),
|
||||
}
|
||||
match &final_ {
|
||||
ReplyEvent::Final { content } => assert_eq!(content, "hello world"),
|
||||
other => panic!("expected Final, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_event_equality_and_clone() {
|
||||
let e = ReplyEvent::Final {
|
||||
content: "done".to_owned(),
|
||||
};
|
||||
assert_eq!(e.clone(), e);
|
||||
|
||||
// Même variante, payload différent ⇒ inégal.
|
||||
assert_ne!(
|
||||
e,
|
||||
ReplyEvent::Final {
|
||||
content: "other".to_owned()
|
||||
}
|
||||
);
|
||||
// Variantes différentes ⇒ inégal.
|
||||
assert_ne!(
|
||||
ReplyEvent::TextDelta { text: "x".into() },
|
||||
ReplyEvent::ToolActivity { label: "x".into() }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentSessionError — variantes + Display / std::error::Error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_session_error_display_messages() {
|
||||
assert_eq!(
|
||||
AgentSessionError::Start("cli missing".to_owned()).to_string(),
|
||||
"agent session start failed: cli missing"
|
||||
);
|
||||
assert_eq!(
|
||||
AgentSessionError::Io("broken pipe".to_owned()).to_string(),
|
||||
"agent session io failed: broken pipe"
|
||||
);
|
||||
assert_eq!(
|
||||
AgentSessionError::Decode("bad json".to_owned()).to_string(),
|
||||
"agent session decode failed: bad json"
|
||||
);
|
||||
assert_eq!(
|
||||
AgentSessionError::Timeout.to_string(),
|
||||
"agent session reply timed out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_session_error_is_std_error_and_equates() {
|
||||
// Conformité au trait std::error::Error (thiserror).
|
||||
fn assert_error<E: std::error::Error>(_e: &E) {}
|
||||
let e = AgentSessionError::Decode("x".to_owned());
|
||||
assert_error(&e);
|
||||
|
||||
// Clone + égalité par variante/payload.
|
||||
assert_eq!(e.clone(), e);
|
||||
assert_ne!(
|
||||
AgentSessionError::Start("a".into()),
|
||||
AgentSessionError::Start("b".into())
|
||||
);
|
||||
assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentSession / AgentSessionFactory — fake in-module (conformité de signature)
|
||||
//
|
||||
// D0 ne fournit PAS d'impl réelle : ce fake prouve seulement que les traits sont
|
||||
// implémentables (object-safe, signatures cohérentes). Aucune logique réelle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
self.conversation_id.clone()
|
||||
}
|
||||
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
// Flux borné minimal : un delta puis le Final déterministe.
|
||||
let stream: ReplyStream = Box::new(
|
||||
vec![
|
||||
ReplyEvent::TextDelta {
|
||||
text: prompt.to_owned(),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: prompt.to_owned(),
|
||||
},
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeFactory;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSessionFactory for FakeFactory {
|
||||
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> {
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
||||
conversation_id: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_session_proves_trait_is_implementable() {
|
||||
let sid = SessionId::from_uuid(Uuid::from_u128(42));
|
||||
let session = FakeSession {
|
||||
id: sid,
|
||||
conversation_id: Some("conv-1".to_owned()),
|
||||
};
|
||||
|
||||
// Consommé comme trait-objet (object-safety du port via #[async_trait]).
|
||||
let dyn_session: Arc<dyn AgentSession> = Arc::new(session);
|
||||
assert_eq!(dyn_session.id(), sid);
|
||||
assert_eq!(dyn_session.conversation_id(), Some("conv-1".to_owned()));
|
||||
|
||||
// send -> flux d'événements borné se terminant par Final (contrat §17.1).
|
||||
let events: Vec<ReplyEvent> = dyn_session.send("ping").await.unwrap().collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
ReplyEvent::TextDelta {
|
||||
text: "ping".to_owned()
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "ping".to_owned()
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(matches!(events.last(), Some(ReplyEvent::Final { .. })));
|
||||
|
||||
dyn_session.shutdown().await.expect("shutdown ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
||||
let factory: Arc<dyn AgentSessionFactory> = Arc::new(FakeFactory);
|
||||
|
||||
let structured = pty_profile().with_structured_adapter(StructuredAdapter::Claude);
|
||||
let pty = pty_profile();
|
||||
assert!(factory.supports(&structured));
|
||||
assert!(!factory.supports(&pty));
|
||||
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||
let session = factory
|
||||
.start(&structured, &ctx, &cwd, &SessionPlan::None)
|
||||
.await
|
||||
.expect("factory starts a session");
|
||||
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
||||
}
|
||||
Reference in New Issue
Block a user