Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant « 1 agent = 1 employé » durci ; un agent traite un tour à la fois. - R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent. - R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup. - R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe, idempotent) — corrige « une cellule reset au retour d'onglet ». - R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule », mapping AGENT_ALREADY_RUNNING. - A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s). Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1160 lines
40 KiB
Rust
1160 lines
40 KiB
Rust
//! LOT D3 (ARCHITECTURE §17, ligne §17.9 D3) — tests du **chemin structuré** de
|
|
//! [`LaunchAgent`] et de la réconciliation A/B, 100 % via des fakes (jamais le vrai
|
|
//! claude/codex).
|
|
//!
|
|
//! Couvre, en injectant une fake [`AgentSessionFactory`] (+ fake [`AgentSession`])
|
|
//! via les builders additifs `with_structured(...)` :
|
|
//!
|
|
//! 1. **Routage `LaunchAgent`** : un profil porteur d'un `structured_adapter` + une
|
|
//! factory câblée ⇒ `factory.start` appelé, session enregistrée dans
|
|
//! [`StructuredSessions`] (retrouvable par `session_for_agent`), **aucun**
|
|
//! `pty.spawn`, `AgentLaunched` publié, `LaunchAgentOutput.structured = Some(..)`
|
|
//! avec les bons `agent_id`/`node_id`/`conversation_id` ; un profil non structuré
|
|
//! suit le chemin PTY inchangé ; invariant « 1 session vivante/agent » côté
|
|
//! structuré (rebind/idempotence, pas de 2e `start`).
|
|
//! 2. **Réconciliation A** (`ChangeAgentProfile`) : session structurée vivante ⇒
|
|
//! `shutdown()` polymorphe (pas un kill PTY) puis relance via la factory ;
|
|
//! session PTY vivante ⇒ A1 d'origine (kill PTY) inchangé ; la détection consulte
|
|
//! les deux registres.
|
|
//! 3. **Réconciliation B** : `resolve_session_plan` renvoie `Resume{conversation_id}`
|
|
//! pour un profil structuré dont la cellule porte une conversation (le runtime
|
|
//! fake capture le `SessionPlan` reçu).
|
|
//!
|
|
//! Le fake `AgentSession` enregistre ses `shutdown()` dans un compteur partagé, et
|
|
//! le fake `AgentSessionFactory` enregistre chaque `start` (profil + plan de session)
|
|
//! — c'est ainsi qu'on prouve « start appelé une seule fois », « shutdown appelé »,
|
|
//! « le bon SessionPlan transmis ».
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|
use domain::events::DomainEvent;
|
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
|
use domain::layout::Workspace;
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{
|
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
|
|
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
|
|
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PreparedContext,
|
|
ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream,
|
|
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use domain::skill::{Skill, SkillScope};
|
|
use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions,
|
|
TerminalSessions,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeContexts (AgentContextStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct ContextsInner {
|
|
manifest: AgentManifest,
|
|
contents: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
|
|
impl FakeContexts {
|
|
fn with_agent(agent: &Agent, content: &str) -> Self {
|
|
let me = Self(Arc::new(Mutex::new(ContextsInner {
|
|
manifest: AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
},
|
|
contents: HashMap::new(),
|
|
})));
|
|
{
|
|
let mut inner = me.0.lock().unwrap();
|
|
inner
|
|
.manifest
|
|
.entries
|
|
.push(ManifestEntry::from_agent(agent));
|
|
inner
|
|
.contents
|
|
.insert(agent.context_path.clone(), content.to_owned());
|
|
}
|
|
me
|
|
}
|
|
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.md_path.clone())
|
|
}
|
|
fn profile_of(&self, agent: &AgentId) -> Option<ProfileId> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.profile_id)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.contents
|
|
.get(&md_path)
|
|
.cloned()
|
|
.map(MarkdownDoc::new)
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.contents
|
|
.insert(md_path, md.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.0.lock().unwrap().manifest.clone())
|
|
}
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().manifest = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeProfiles (ProfileStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
|
|
|
impl FakeProfiles {
|
|
fn new(profiles: Vec<AgentProfile>) -> Self {
|
|
Self(Arc::new(profiles))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok((*self.0).clone())
|
|
}
|
|
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
Ok(true)
|
|
}
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeStore (ProjectStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default, Clone)]
|
|
struct FakeStore(Arc<Mutex<Vec<Project>>>);
|
|
|
|
#[async_trait]
|
|
impl ProjectStore for FakeStore {
|
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|p| p.id == id)
|
|
.cloned()
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().push(project.clone());
|
|
Ok(())
|
|
}
|
|
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
|
Ok(Workspace::default())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeFs (FileSystem) — HashMap-backed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct FakeFsInner {
|
|
files: HashMap<String, Vec<u8>>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct FakeFs(Arc<Mutex<FakeFsInner>>);
|
|
|
|
#[async_trait]
|
|
impl FileSystem for FakeFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.files
|
|
.get(path.as_str())
|
|
.cloned()
|
|
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
|
}
|
|
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.files
|
|
.insert(path.as_str().to_owned(), data.to_vec());
|
|
Ok(())
|
|
}
|
|
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
|
Ok(false)
|
|
}
|
|
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeRuntime (AgentRuntime) — capture le SessionPlan reçu (B)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct FakeRuntime {
|
|
last_session: Arc<Mutex<Option<SessionPlan>>>,
|
|
}
|
|
|
|
impl FakeRuntime {
|
|
fn new() -> Self {
|
|
Self {
|
|
last_session: Arc::new(Mutex::new(None)),
|
|
}
|
|
}
|
|
fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
|
|
Arc::clone(&self.last_session)
|
|
}
|
|
}
|
|
|
|
impl AgentRuntime for FakeRuntime {
|
|
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
*self.last_session.lock().unwrap() = Some(session.clone());
|
|
Ok(SpawnSpec {
|
|
command: profile.command.clone(),
|
|
args: profile.args.clone(),
|
|
cwd: cwd.clone(),
|
|
env: Vec::new(),
|
|
context_plan: Some(ContextInjectionPlan::File {
|
|
target: "CLAUDE.md".to_owned(),
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakePty (PtyPort) — records spawns and kills
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakePty {
|
|
next_id: SessionId,
|
|
spawns: Arc<Mutex<Vec<SpawnSpec>>>,
|
|
kills: Arc<Mutex<Vec<SessionId>>>,
|
|
}
|
|
|
|
impl FakePty {
|
|
fn new(next_id: SessionId) -> Self {
|
|
Self {
|
|
next_id,
|
|
spawns: Arc::new(Mutex::new(Vec::new())),
|
|
kills: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
fn spawn_count(&self) -> usize {
|
|
self.spawns.lock().unwrap().len()
|
|
}
|
|
fn kills(&self) -> Vec<SessionId> {
|
|
self.kills.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PtyPort for FakePty {
|
|
async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
|
self.spawns.lock().unwrap().push(spec);
|
|
Ok(PtyHandle {
|
|
session_id: self.next_id,
|
|
})
|
|
}
|
|
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
self.kills.lock().unwrap().push(handle.session_id);
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeSkills / FakeRecall / SpyBus / SeqIds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FakeSkills;
|
|
|
|
#[async_trait]
|
|
impl SkillStore for FakeSkills {
|
|
async fn list(&self, _s: SkillScope, _r: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_s: SkillScope,
|
|
_r: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
Err(StoreError::NotFound)
|
|
}
|
|
async fn save(&self, _skill: &Skill, _r: &ProjectPath) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_s: SkillScope,
|
|
_r: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FakeRecall;
|
|
|
|
#[async_trait]
|
|
impl MemoryRecall for FakeRecall {
|
|
async fn recall(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_query: &MemoryQuery,
|
|
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl SeqIds {
|
|
fn new() -> Self {
|
|
Self(Mutex::new(1))
|
|
}
|
|
}
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeAgentSession + FakeAgentSessionFactory (le cœur du chemin structuré)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Session structurée fake : id + conversation id figés, `shutdown()` enregistré
|
|
/// dans un compteur partagé (preuve du kill polymorphe), `send()` borné minimal.
|
|
struct FakeAgentSession {
|
|
id: SessionId,
|
|
conversation_id: Option<String>,
|
|
shutdowns: Arc<AtomicUsize>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for FakeAgentSession {
|
|
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> {
|
|
let stream: ReplyStream = Box::new(
|
|
vec![ReplyEvent::Final {
|
|
content: prompt.to_owned(),
|
|
}]
|
|
.into_iter(),
|
|
);
|
|
Ok(stream)
|
|
}
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Fabrique fake : enregistre chaque `start` (profil + SessionPlan reçus) et rend une
|
|
/// [`FakeAgentSession`] avec l'id/conversation configurés. Partage le compteur de
|
|
/// `shutdown` avec les sessions qu'elle crée, pour prouver le kill polymorphe.
|
|
#[derive(Clone)]
|
|
struct FakeFactory {
|
|
/// Id de session attribué aux sessions créées (incrémenté à chaque start).
|
|
next_id: Arc<Mutex<u128>>,
|
|
/// L'id de conversation moteur que la session exposera (`None` = neuf).
|
|
conversation_id: Option<String>,
|
|
/// Trace des `(command, SessionPlan)` reçus par `start`.
|
|
starts: Arc<Mutex<Vec<(String, SessionPlan)>>>,
|
|
/// Compteur de `shutdown()` partagé avec les sessions créées.
|
|
shutdowns: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl FakeFactory {
|
|
fn new(first_id: u128, conversation_id: Option<&str>) -> Self {
|
|
Self {
|
|
next_id: Arc::new(Mutex::new(first_id)),
|
|
conversation_id: conversation_id.map(str::to_owned),
|
|
starts: Arc::new(Mutex::new(Vec::new())),
|
|
shutdowns: Arc::new(AtomicUsize::new(0)),
|
|
}
|
|
}
|
|
fn start_count(&self) -> usize {
|
|
self.starts.lock().unwrap().len()
|
|
}
|
|
fn starts(&self) -> Vec<(String, SessionPlan)> {
|
|
self.starts.lock().unwrap().clone()
|
|
}
|
|
fn shutdown_count(&self) -> usize {
|
|
self.shutdowns.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
#[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> {
|
|
self.starts
|
|
.lock()
|
|
.unwrap()
|
|
.push((profile.command.clone(), session.clone()));
|
|
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(FakeAgentSession {
|
|
id,
|
|
conversation_id: self.conversation_id.clone(),
|
|
shutdowns: Arc::clone(&self.shutdowns),
|
|
}))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ROOT: &str = "/home/me/proj";
|
|
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn nid(n: u128) -> NodeId {
|
|
NodeId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new(ROOT).unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
/// Profil **structuré** (porte un `structured_adapter`), convention file CLAUDE.md.
|
|
fn structured_profile(id: ProfileId) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude Structuré",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
.with_structured_adapter(StructuredAdapter::Claude)
|
|
}
|
|
|
|
/// Profil **non structuré** (chemin PTY), convention file CLAUDE.md.
|
|
fn pty_profile(id: ProfileId) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude PTY",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent {
|
|
Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap()
|
|
}
|
|
|
|
fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
|
LaunchAgentInput {
|
|
project: project(),
|
|
agent_id,
|
|
rows: 24,
|
|
cols: 80,
|
|
node_id: None,
|
|
conversation_id: None,
|
|
}
|
|
}
|
|
|
|
/// Inserts a live PTY agent session into the registry, pinned on `node`.
|
|
fn seed_live_pty_session(
|
|
sessions: &TerminalSessions,
|
|
agent_id: AgentId,
|
|
node: NodeId,
|
|
session_id: SessionId,
|
|
) {
|
|
let size = PtySize::new(24, 80).unwrap();
|
|
let mut session = domain::TerminalSession::starting(
|
|
session_id,
|
|
node,
|
|
ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(),
|
|
SessionKind::Agent { agent_id },
|
|
size,
|
|
);
|
|
session.status = domain::SessionStatus::Running;
|
|
sessions.insert(PtyHandle { session_id }, session);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture (LaunchAgent câblé structuré)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct LaunchFixture {
|
|
launch: Arc<LaunchAgent>,
|
|
agent: Agent,
|
|
pty: FakePty,
|
|
bus: SpyBus,
|
|
sessions: Arc<TerminalSessions>,
|
|
structured: Arc<StructuredSessions>,
|
|
factory: FakeFactory,
|
|
session_probe: Arc<Mutex<Option<SessionPlan>>>,
|
|
}
|
|
|
|
/// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory.
|
|
fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture {
|
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
|
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
|
let profiles = FakeProfiles::new(vec![profile]);
|
|
let runtime = FakeRuntime::new();
|
|
let session_probe = runtime.session_probe();
|
|
let fs = FakeFs::default();
|
|
let pty = FakePty::new(sid(777));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
let bus = SpyBus::default();
|
|
let launch = LaunchAgent::new(
|
|
Arc::new(contexts),
|
|
Arc::new(profiles),
|
|
Arc::new(runtime),
|
|
Arc::new(fs),
|
|
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()), Arc::clone(&structured));
|
|
LaunchFixture {
|
|
launch: Arc::new(launch),
|
|
agent,
|
|
pty,
|
|
bus,
|
|
sessions,
|
|
structured,
|
|
factory,
|
|
session_probe,
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 1. Routage LaunchAgent : chemin structuré
|
|
// ===========================================================================
|
|
|
|
/// Profil structuré + factory câblée ⇒ `factory.start` appelé une fois, session
|
|
/// enregistrée dans `StructuredSessions`, AUCUN `pty.spawn`, `AgentLaunched` publié,
|
|
/// `output.structured = Some(descriptor)` avec les bons champs.
|
|
#[tokio::test]
|
|
async fn structured_launch_starts_session_registers_no_pty_spawn() {
|
|
// Factory : 1re session id = 500, conversation moteur "engine-conv".
|
|
let factory = FakeFactory::new(500, Some("engine-conv"));
|
|
let f = launch_fixture(structured_profile(pid(9)), factory);
|
|
|
|
let mut input = launch_input(f.agent.id);
|
|
input.node_id = Some(nid(3));
|
|
let out = f.launch.execute(input).await.expect("structured launch");
|
|
|
|
// factory.start appelé exactement une fois.
|
|
assert_eq!(f.factory.start_count(), 1, "factory.start called once");
|
|
// AUCUN spawn PTY.
|
|
assert_eq!(f.pty.spawn_count(), 0, "no pty spawn on structured path");
|
|
|
|
// La session est enregistrée dans le registre structuré, retrouvable par agent.
|
|
let registered = f
|
|
.structured
|
|
.session_for_agent(&f.agent.id)
|
|
.expect("structured session registered");
|
|
assert_eq!(registered.id(), sid(500), "session id is the factory's");
|
|
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(3)));
|
|
// Rien côté registre PTY.
|
|
assert!(f.sessions.session_for_agent(&f.agent.id).is_none());
|
|
|
|
// AgentLaunched publié avec l'id de session structurée.
|
|
assert_eq!(
|
|
f.bus.events(),
|
|
vec![DomainEvent::AgentLaunched {
|
|
agent_id: f.agent.id,
|
|
session_id: sid(500),
|
|
}]
|
|
);
|
|
|
|
// output.structured renseigné avec les bons agent/node/conversation.
|
|
let desc = out.structured.expect("structured descriptor present");
|
|
assert_eq!(desc.session_id, sid(500));
|
|
assert_eq!(desc.agent_id, f.agent.id);
|
|
assert_eq!(desc.node_id, nid(3));
|
|
assert_eq!(desc.conversation_id.as_deref(), Some("engine-conv"));
|
|
|
|
// Le snapshot de session reste cohérent (kind = Agent, id = celui de la session).
|
|
assert_eq!(out.session.id, sid(500));
|
|
assert!(matches!(
|
|
out.session.kind,
|
|
SessionKind::Agent { agent_id } if agent_id == f.agent.id
|
|
));
|
|
// L'id de conversation moteur est exposé pour persistance sur la cellule.
|
|
assert_eq!(out.assigned_conversation_id.as_deref(), Some("engine-conv"));
|
|
}
|
|
|
|
/// Profil **non structuré** (même câblage structuré présent) ⇒ chemin PTY inchangé :
|
|
/// `pty.spawn` appelé, factory jamais sollicitée, `output.structured = None`.
|
|
#[tokio::test]
|
|
async fn non_structured_profile_takes_pty_path_unchanged() {
|
|
let factory = FakeFactory::new(500, Some("engine-conv"));
|
|
let f = launch_fixture(pty_profile(pid(9)), factory);
|
|
|
|
let out = f
|
|
.launch
|
|
.execute(launch_input(f.agent.id))
|
|
.await
|
|
.expect("pty launch");
|
|
|
|
// PTY spawn appelé, factory jamais sollicitée.
|
|
assert_eq!(f.pty.spawn_count(), 1, "pty path spawns");
|
|
assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile");
|
|
|
|
// Session côté registre PTY, rien côté structuré.
|
|
assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777)));
|
|
assert!(f.structured.session_for_agent(&f.agent.id).is_none());
|
|
|
|
// output.structured = None ; session PTY classique.
|
|
assert!(out.structured.is_none(), "no structured descriptor on pty path");
|
|
assert_eq!(out.session.id, sid(777));
|
|
}
|
|
|
|
/// Invariant « 1 agent = 1 employé » côté structuré (R0a) : un **lancement neuf**
|
|
/// (sans `conversation_id`) sur une **autre** cellule B d'un agent structuré déjà
|
|
/// vivant en cellule A est un second lancement ⇒ **refus**
|
|
/// `AppError::AgentAlreadyRunning { node_id: A }`, **pas** de 2e `factory.start`,
|
|
/// une seule session structurée vivante.
|
|
#[tokio::test]
|
|
async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
|
|
let factory = FakeFactory::new(500, Some("engine-conv"));
|
|
let f = launch_fixture(structured_profile(pid(9)), factory);
|
|
|
|
// 1er lancement sur la cellule A.
|
|
let host = nid(1);
|
|
let mut first = launch_input(f.agent.id);
|
|
first.node_id = Some(host);
|
|
f.launch.execute(first).await.expect("first launch");
|
|
assert_eq!(f.factory.start_count(), 1);
|
|
assert_eq!(f.structured.len(), 1);
|
|
|
|
// Lancement NEUF (conversation_id: None) dans une cellule B ⇒ refus.
|
|
let mut second = launch_input(f.agent.id);
|
|
let target = nid(2);
|
|
second.node_id = Some(target);
|
|
let err = second_launch_err(&f, second).await;
|
|
|
|
match err {
|
|
application::AppError::AgentAlreadyRunning { agent_id, node_id } => {
|
|
assert_eq!(agent_id, f.agent.id, "reports the live agent");
|
|
assert_eq!(node_id, host, "reports the live HOST node A, not target B");
|
|
}
|
|
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
|
|
}
|
|
|
|
assert_eq!(
|
|
f.factory.start_count(),
|
|
1,
|
|
"no second factory.start on a refused launch"
|
|
);
|
|
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
|
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
|
assert_eq!(
|
|
f.structured.node_for_agent(&f.agent.id),
|
|
Some(host),
|
|
"session stays pinned on its host node A"
|
|
);
|
|
}
|
|
|
|
/// **Réattache légitime même node (R0a, structuré)** : relancer sur la **même**
|
|
/// cellule hôte ⇒ rebind, pas d'erreur, pas de 2e `factory.start`, même session.
|
|
#[tokio::test]
|
|
async fn structured_relaunch_same_node_rebinds_no_second_start() {
|
|
let factory = FakeFactory::new(500, Some("engine-conv"));
|
|
let f = launch_fixture(structured_profile(pid(9)), factory);
|
|
|
|
let host = nid(1);
|
|
let mut first = launch_input(f.agent.id);
|
|
first.node_id = Some(host);
|
|
f.launch.execute(first).await.expect("first launch");
|
|
assert_eq!(f.factory.start_count(), 1);
|
|
|
|
// Re-ouverture de la MÊME cellule.
|
|
let mut again = launch_input(f.agent.id);
|
|
again.node_id = Some(host);
|
|
let out = f.launch.execute(again).await.expect("same-node rebind");
|
|
|
|
assert_eq!(f.factory.start_count(), 1, "no second factory.start");
|
|
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
|
assert_eq!(f.structured.len(), 1, "single live structured session");
|
|
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(host));
|
|
let desc = out.structured.expect("descriptor on rebind");
|
|
assert_eq!(desc.session_id, sid(500), "same live session id");
|
|
assert_eq!(desc.node_id, host);
|
|
}
|
|
|
|
/// **Réattache explicite par conversation (R0a, structuré)** : relancer sur une
|
|
/// **autre** cellule B mais avec un `conversation_id` ⇒ rebind légitime de la vue,
|
|
/// pas d'erreur, pas de 2e `factory.start`, même session, vue déplacée sur B.
|
|
#[tokio::test]
|
|
async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
|
|
let factory = FakeFactory::new(500, Some("engine-conv"));
|
|
let f = launch_fixture(structured_profile(pid(9)), factory);
|
|
|
|
let host = nid(1);
|
|
let mut first = launch_input(f.agent.id);
|
|
first.node_id = Some(host);
|
|
f.launch.execute(first).await.expect("first launch");
|
|
assert_eq!(f.factory.start_count(), 1);
|
|
|
|
// Cellule B + signal de réattache explicite.
|
|
let mut second = launch_input(f.agent.id);
|
|
let target = nid(2);
|
|
second.node_id = Some(target);
|
|
second.conversation_id = Some("conv-live".to_owned());
|
|
let out = f.launch.execute(second).await.expect("explicit reattach");
|
|
|
|
assert_eq!(
|
|
f.factory.start_count(),
|
|
1,
|
|
"no second factory.start on explicit reattach"
|
|
);
|
|
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
|
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
|
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target));
|
|
let desc = out.structured.expect("descriptor on rebind");
|
|
assert_eq!(desc.session_id, sid(500), "same live session id");
|
|
assert_eq!(desc.node_id, target);
|
|
}
|
|
|
|
/// Helper: executes a launch that is expected to be refused, returning the error.
|
|
async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError {
|
|
f.launch
|
|
.execute(input)
|
|
.await
|
|
.expect_err("fresh second launch elsewhere is refused")
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 2. Réconciliation A : ChangeAgentProfile (kill polymorphe)
|
|
// ===========================================================================
|
|
|
|
/// Câble un `ChangeAgentProfile.with_structured(...)` partageant les mêmes registres
|
|
/// (PTY + structuré) et la même factory que le `LaunchAgent` composé.
|
|
struct SwapFixture {
|
|
swap: ChangeAgentProfile,
|
|
contexts: FakeContexts,
|
|
pty: FakePty,
|
|
sessions: Arc<TerminalSessions>,
|
|
structured: Arc<StructuredSessions>,
|
|
factory: FakeFactory,
|
|
}
|
|
|
|
/// Wires the swap use case. Both `pid(1)` (current) and `pid(2)` (target) are known;
|
|
/// `pid(2)` is structured so a relaunch routes through the factory.
|
|
fn swap_fixture(agent: &Agent, target_structured: bool, factory: FakeFactory) -> SwapFixture {
|
|
let target = if target_structured {
|
|
structured_profile(pid(2))
|
|
} else {
|
|
pty_profile(pid(2))
|
|
};
|
|
let profiles_vec = vec![structured_profile(pid(1)), target];
|
|
|
|
let contexts = FakeContexts::with_agent(agent, "# persona");
|
|
let profiles = FakeProfiles::new(profiles_vec);
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
let pty = FakePty::new(sid(777));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
let bus = SpyBus::default();
|
|
|
|
// Register the project so ProjectStore::load_project resolves (for conv cleanup).
|
|
{
|
|
let mut v = store.0.lock().unwrap();
|
|
v.push(project());
|
|
}
|
|
|
|
let launch = LaunchAgent::new(
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(profiles.clone()),
|
|
Arc::new(FakeRuntime::new()),
|
|
Arc::new(fs.clone()),
|
|
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()), Arc::clone(&structured));
|
|
|
|
let swap = ChangeAgentProfile::new(
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(profiles),
|
|
Arc::new(store),
|
|
Arc::new(fs),
|
|
Arc::clone(&sessions),
|
|
Arc::new(pty.clone()),
|
|
Arc::new(launch),
|
|
Arc::new(bus),
|
|
)
|
|
.with_structured(Arc::clone(&structured));
|
|
|
|
SwapFixture {
|
|
swap,
|
|
contexts,
|
|
pty,
|
|
sessions,
|
|
structured,
|
|
factory,
|
|
}
|
|
}
|
|
|
|
fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput {
|
|
ChangeAgentProfileInput {
|
|
project: project(),
|
|
agent_id,
|
|
profile_id,
|
|
rows: 24,
|
|
cols: 80,
|
|
}
|
|
}
|
|
|
|
/// Agent avec session **structurée** vivante ⇒ changement de profil ⇒ `shutdown()`
|
|
/// appelé sur la session structurée (PAS un kill PTY), puis relance via la factory.
|
|
#[tokio::test]
|
|
async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
|
// La factory crée la session de relance (id 600) ; la session vivante initiale
|
|
// partage le même compteur de shutdown via la factory.
|
|
let factory = FakeFactory::new(600, Some("relaunch-conv"));
|
|
let f = swap_fixture(&agent, true, factory);
|
|
|
|
// Pré-seed : une session structurée vivante (id 500) sur la cellule N, via la
|
|
// MÊME factory pour partager le compteur de shutdown.
|
|
let host = nid(5);
|
|
{
|
|
// Démarre une session "manuellement" pour la pré-seeder dans le registre.
|
|
let profile = structured_profile(pid(1));
|
|
let ctx = PreparedContext {
|
|
content: MarkdownDoc::new("# persona"),
|
|
relative_path: "agents/backend.md".to_owned(),
|
|
};
|
|
let cwd = ProjectPath::new(ROOT).unwrap();
|
|
let session = f
|
|
.factory
|
|
.start(&profile, &ctx, &cwd, &SessionPlan::None)
|
|
.await
|
|
.expect("seed structured session");
|
|
f.structured.insert(session, agent.id, host);
|
|
}
|
|
// La factory a maintenant été appelée 1 fois (le seed) ; reset logique : on
|
|
// comptera les start APRÈS, donc on mémorise la base.
|
|
let starts_before = f.factory.start_count();
|
|
assert_eq!(starts_before, 1, "seed used one start");
|
|
assert_eq!(f.structured.len(), 1);
|
|
|
|
let out = f
|
|
.swap
|
|
.execute(change_input(agent.id, pid(2)))
|
|
.await
|
|
.expect("hot swap succeeds");
|
|
|
|
// shutdown() appelé sur la session structurée (kill polymorphe), PAS de kill PTY.
|
|
assert_eq!(
|
|
f.factory.shutdown_count(),
|
|
1,
|
|
"structured session shut down exactly once"
|
|
);
|
|
assert!(
|
|
f.pty.kills().is_empty(),
|
|
"no PTY kill for a structured live session"
|
|
);
|
|
assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)");
|
|
|
|
// Relance via la factory : un nouveau start (donc total 2).
|
|
assert_eq!(
|
|
f.factory.start_count(),
|
|
2,
|
|
"exactly one relaunch start after the seed"
|
|
);
|
|
// La nouvelle session structurée (id 600) est enregistrée sur la même cellule.
|
|
// `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession)
|
|
// — on vérifie donc le registre structuré + le snapshot.
|
|
// Seed consumed id 600; the relaunch's session is the factory's next id (601).
|
|
let relaunched = out.relaunched.expect("a live structured agent is relaunched");
|
|
assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id");
|
|
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
|
assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601)));
|
|
assert_eq!(f.structured.node_for_agent(&agent.id), Some(host));
|
|
assert_eq!(f.structured.len(), 1, "single live structured session after swap");
|
|
// Manifeste muté vers le nouveau profil.
|
|
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
|
}
|
|
|
|
/// Agent avec session **PTY** vivante ⇒ comportement A1 d'origine (kill PTY)
|
|
/// inchangé (non-régression), même quand le registre structuré est branché.
|
|
#[tokio::test]
|
|
async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
|
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
|
// Cible PTY (pid(2) non structuré) ⇒ relance par spawn PTY.
|
|
let factory = FakeFactory::new(600, None);
|
|
let f = swap_fixture(&agent, false, factory);
|
|
|
|
// Pré-seed : session PTY vivante (id 42) sur la cellule N.
|
|
let host = nid(5);
|
|
seed_live_pty_session(&f.sessions, agent.id, host, sid(42));
|
|
|
|
let out = f
|
|
.swap
|
|
.execute(change_input(agent.id, pid(2)))
|
|
.await
|
|
.expect("hot swap succeeds");
|
|
|
|
// A1 d'origine : le PTY vivant est tué.
|
|
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
|
|
// Aucune session structurée n'a été créée ni shutdown.
|
|
assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap");
|
|
assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown");
|
|
// Relance PTY : un spawn, même cellule.
|
|
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
|
|
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
|
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
|
assert_eq!(relaunched.id, sid(777));
|
|
// The relaunched session lives in the PTY registry, not the structured one.
|
|
assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777)));
|
|
assert!(f.structured.session_for_agent(&agent.id).is_none());
|
|
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
|
}
|
|
|
|
/// Détection de session vivante : un agent **dead** (aucune session dans aucun des
|
|
/// deux registres) ⇒ pas de kill, pas de shutdown, pas de relance ; manifeste muté.
|
|
#[tokio::test]
|
|
async fn swap_dead_agent_no_kill_no_shutdown_no_relaunch() {
|
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
|
let factory = FakeFactory::new(600, Some("c"));
|
|
let f = swap_fixture(&agent, true, factory);
|
|
|
|
let out = f
|
|
.swap
|
|
.execute(change_input(agent.id, pid(2)))
|
|
.await
|
|
.expect("swap succeeds");
|
|
|
|
assert!(out.relaunched.is_none());
|
|
assert!(f.structured.is_empty(), "no structured session created");
|
|
assert!(f.pty.kills().is_empty());
|
|
assert_eq!(f.factory.shutdown_count(), 0);
|
|
assert_eq!(f.factory.start_count(), 0, "nothing to relaunch");
|
|
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 3. Réconciliation B : resolve_session_plan pour un profil structuré
|
|
// ===========================================================================
|
|
|
|
/// Une cellule structurée portant une conversation ⇒ le plan de session transmis au
|
|
/// runtime est `Resume{conversation_id}` (le runtime fake le capture), même sans
|
|
/// bloc `session` sur le profil (la reprise structurée dépend de l'adapter).
|
|
#[tokio::test]
|
|
async fn structured_profile_with_cell_conversation_resolves_to_resume() {
|
|
let factory = FakeFactory::new(500, Some("engine-conv"));
|
|
let f = launch_fixture(structured_profile(pid(9)), factory);
|
|
|
|
let mut input = launch_input(f.agent.id);
|
|
input.conversation_id = Some("conv-existing".to_owned());
|
|
f.launch.execute(input).await.expect("launch resumes");
|
|
|
|
// Le runtime (prepare_invocation) a reçu un SessionPlan::Resume avec l'id cellule.
|
|
assert_eq!(
|
|
*f.session_probe.lock().unwrap(),
|
|
Some(SessionPlan::Resume {
|
|
conversation_id: "conv-existing".to_owned()
|
|
}),
|
|
"structured profile with a cell conversation must resume"
|
|
);
|
|
// Le plan Resume est aussi celui transmis à la factory.start (preuve bout-en-bout).
|
|
let starts = f.factory.starts();
|
|
assert_eq!(starts.len(), 1);
|
|
assert_eq!(
|
|
starts[0].1,
|
|
SessionPlan::Resume {
|
|
conversation_id: "conv-existing".to_owned()
|
|
}
|
|
);
|
|
}
|
|
|
|
/// Une cellule structurée **neuve** (pas de conversation) sur un profil sans bloc
|
|
/// `session` ⇒ `SessionPlan::None` (rien à reprendre ; l'id moteur sera capté au 1er
|
|
/// tour).
|
|
#[tokio::test]
|
|
async fn structured_profile_fresh_cell_resolves_to_none() {
|
|
let factory = FakeFactory::new(500, None);
|
|
let f = launch_fixture(structured_profile(pid(9)), factory);
|
|
|
|
f.launch
|
|
.execute(launch_input(f.agent.id))
|
|
.await
|
|
.expect("fresh launch");
|
|
|
|
let starts = f.factory.starts();
|
|
assert_eq!(starts.len(), 1);
|
|
assert_eq!(
|
|
starts[0].1,
|
|
SessionPlan::None,
|
|
"fresh structured cell without a session block plans None"
|
|
);
|
|
}
|