Files
IdeA/crates/application/tests/structured_launch_d3.rs
Blomios 56913b9053 feat(agent): routage LaunchAgent structuré vs PTY + réconciliation A/B (D3) — §17
- LaunchAgent route sur profile.structured_adapter : Some ⇒ AgentSession via
  factory + enregistrement StructuredSessions (aucun pty.spawn) ; None ⇒ PTY
  inchangé. Dépendances structurées injectées par builders additifs
  with_structured (signatures publiques inchangées ⇒ A/B legacy verts).
- LaunchAgentOutput étendu d'un champ optionnel structured (non cassant).
- A (ChangeAgentProfile) : kill polymorphe — session structurée ⇒ shutdown(),
  PTY ⇒ kill ; détection sur les deux registres.
- B : resume_supported vrai pour profil structuré ; resolve_session_plan ⇒
  Resume{conversation_id} (Claude --resume / Codex exec resume).
- Codex : build_spawn_line porte --sandbox workspace-write --ask-for-approval
  never (autonomie d'écriture ; à terme piloté par les permissions).

Tests : structured_launch 8 + codex flags 2 ; application/infrastructure 0 échec.

Reste D4 : ChatBridge (Channel) + commandes agent_send/reattach + StructuredSessions
dans AppState + DTO cellKind/ReplyChunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:48:25 +02:00

1078 lines
36 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 session vivante/agent » côté structuré : relancer un agent
/// structuré déjà vivant ⇒ rebind/idempotent (pas de 2e `factory.start`).
#[tokio::test]
async fn structured_relaunch_is_idempotent_no_second_start() {
let factory = FakeFactory::new(500, Some("engine-conv"));
let f = launch_fixture(structured_profile(pid(9)), factory);
// 1er lancement sur la cellule A.
let mut first = launch_input(f.agent.id);
first.node_id = Some(nid(1));
f.launch.execute(first).await.expect("first launch");
assert_eq!(f.factory.start_count(), 1);
assert_eq!(f.structured.len(), 1);
// Relance dans une cellule B : rebind de la vue, PAS de 2e start.
let mut second = launch_input(f.agent.id);
second.node_id = Some(nid(2));
let out = f.launch.execute(second).await.expect("relaunch");
assert_eq!(
f.factory.start_count(),
1,
"no second factory.start on relaunch of a live structured agent"
);
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
assert_eq!(f.structured.len(), 1, "still a single live structured session");
// La vue est rebindée sur la cellule B.
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(2)));
let desc = out.structured.expect("descriptor on rebind");
assert_eq!(desc.session_id, sid(500), "same live session id");
assert_eq!(desc.node_id, nid(2));
}
// ===========================================================================
// 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"
);
}