Files
IdeA/crates/application/tests/structured_launch_d3.rs
Blomios 62b71776f5 fix(background-task): découpler la détection de fin de tâche du drain output PTY (#2)
Le runner de tâches de fond détectait la fin d'un process via l'EOF du drain de
sortie PTY, couplant le cycle de vie du process au flux d'output. Un output
encore ouvert (ou drainé ailleurs) pouvait masquer ou retarder la détection de
fin.

Ajoute wait/try_wait au port figé PtyPort :
- wait : bloquant, rend un ExitStatus idempotent ;
- try_wait : non bloquant, rend Option<ExitStatus> ;
- exit status mémorisé pour garder wait/try_wait/kill cohérents.
Implémentation dans PortablePtyAdapter (état d'exit mémorisé). Le
CommandBackgroundRunner détecte désormais la fin via pty.wait (select sur
cancel/deadline) au lieu de l'EOF du drain. Tous les fakes PtyPort du workspace
sont complétés en conséquence.

Le tee live UI reste hors périmètre (traité en #58). Aucun breaking IPC/front.

Tests : infrastructure/tests/background_task_runner.rs (nouveau) + pty_adapter.rs
verts, non-régression application/app-tauri OK (hors échecs réseau du sandbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:42:40 +02:00

1757 lines
61 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(),
orchestrator: None,
},
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)
}
}
#[async_trait]
impl AgentRuntime for FakeRuntime {
async 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(),
}),
sandbox: None,
})
}
}
// ---------------------------------------------------------------------------
// 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 wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) })
}
fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
Ok(Some(ExitStatus { code: Some(0) }))
}
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,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> 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),
}))
}
}
// ---------------------------------------------------------------------------
// FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b)
// ---------------------------------------------------------------------------
use application::ProviderSessionProvider;
use domain::{ConversationId, ProviderSessionStore};
/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
/// `(conversation, provider_id) → resumable_id` map, observable after the launch.
/// `set` can be made to fail (best-effort scenario) so we can prove a write error
/// never degrades the launch.
#[derive(Clone, Default)]
struct FakeProviderSessionStore {
entries: Arc<Mutex<HashMap<(ConversationId, String), String>>>,
fail_set: bool,
}
impl FakeProviderSessionStore {
fn new() -> Self {
Self::default()
}
/// A store whose `set` always errors (best-effort proof).
fn failing() -> Self {
Self {
entries: Arc::new(Mutex::new(HashMap::new())),
fail_set: true,
}
}
/// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously,
/// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine
/// resumable for that pair (mirrors what a previous P8b launch would have stored).
fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) {
self.entries
.lock()
.unwrap()
.insert((conversation, provider.to_owned()), resumable_id.to_owned());
}
/// Observed resumable id for a `(conversation, provider)` couple, if any.
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
self.entries
.lock()
.unwrap()
.get(&(conversation, provider.to_owned()))
.cloned()
}
fn len(&self) -> usize {
self.entries.lock().unwrap().len()
}
}
#[async_trait]
impl ProviderSessionStore for FakeProviderSessionStore {
async fn get(
&self,
conversation: ConversationId,
provider_id: &str,
) -> Result<Option<String>, StoreError> {
Ok(self.get_sync(conversation, provider_id))
}
async fn set(
&self,
conversation: ConversationId,
provider_id: &str,
resumable_id: &str,
) -> Result<(), StoreError> {
if self.fail_set {
return Err(StoreError::Io("forced set failure".to_owned()));
}
self.entries.lock().unwrap().insert(
(conversation, provider_id.to_owned()),
resumable_id.to_owned(),
);
Ok(())
}
}
/// [`ProviderSessionProvider`] that hands back the same store for any root (the
/// launch tests use a single project root).
#[derive(Clone)]
struct FakeProviderSessionProvider(Arc<FakeProviderSessionStore>);
impl ProviderSessionProvider for FakeProviderSessionProvider {
fn provider_session_store_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<dyn ProviderSessionStore>> {
Some(Arc::clone(&self.0) as Arc<dyn ProviderSessionStore>)
}
}
// ---------------------------------------------------------------------------
// 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,
mcp_runtime: None,
allow_structured_alongside_pty: false,
}
}
/// 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
));
// P8a (ARCHITECTURE §19.7) : la CELLULE porte l'**id de paire IdeA**, pas l'id
// moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(User, agent)`
// dérivé via `ConversationId::for_pair` = l'UUID de l'agent (`aid(1)`).
assert_eq!(
out.assigned_conversation_id.as_deref(),
Some("00000000-0000-0000-0000-000000000001"),
"cell carries the IdeA pair id (pivot logique), not the engine resumable"
);
// L'id de session MOTEUR (resumable provider) part dans le cache séparé.
assert_eq!(
out.engine_session_id.as_deref(),
Some("engine-conv"),
"engine resumable id is cached separately from the pair key"
);
}
/// 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(),
project_root: ROOT.to_owned(),
};
let cwd = ProjectPath::new(ROOT).unwrap();
let session = f
.factory
.start(&profile, &ctx, &cwd, &SessionPlan::None, &[], 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"
);
}
// ===========================================================================
// LOT P8b — écriture de `providers.json` au lancement structuré
//
// Prouve `persist_provider_session` (best-effort) : après un lancement structuré
// exposant un id de session moteur, IdeA range `(pair, provider_key) →
// engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de
// skip (provider absent, id moteur absent) et la robustesse (set en échec) sont
// couvertes — le lancement réussit toujours.
// ===========================================================================
/// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé
/// provider `"codex"`.
fn structured_codex_profile(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"Codex Structuré",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").unwrap(),
Some("codex --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Codex)
}
/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)`
/// over the given profile/factory, returning the launcher, the agent and the
/// observable provider store. When `provider` is `None`, no provider is wired
/// (legacy path).
fn launch_fixture_p8b(
profile: AgentProfile,
factory: FakeFactory,
store: Option<Arc<FakeProviderSessionStore>>,
) -> (Arc<LaunchAgent>, Agent) {
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 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 mut launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(runtime),
Arc::new(fs),
Arc::new(pty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_structured(Arc::new(factory), Arc::clone(&structured));
if let Some(store) = store {
let provider =
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
launch = launch.with_provider_session_provider(provider);
}
(Arc::new(launch), agent)
}
/// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant
/// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id`
/// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de
/// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée).
#[tokio::test]
async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, Some("engine-xyz"));
let (launch, agent) = launch_fixture_p8b(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
// La cellule porte un id de paire UUID valide : c'est lui qui sert de clé.
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("structured launch");
assert_eq!(
store.get_sync(pair, "claude").as_deref(),
Some("engine-xyz"),
"engine resumable stored under (pair, claude)"
);
assert_eq!(store.len(), 1, "exactly one entry written");
// La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas
// l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien.
assert!(
store.get_sync(pair, "codex").is_none(),
"nothing written under a different provider key"
);
}
/// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider
/// `"codex"`.
#[tokio::test]
async fn p8b_nominal_codex_writes_engine_id_under_codex_key() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, Some("engine-codex"));
let (launch, agent) = launch_fixture_p8b(
structured_codex_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("structured launch");
assert_eq!(
store.get_sync(pair, "codex").as_deref(),
Some("engine-codex"),
"engine resumable stored under (pair, codex)"
);
assert_eq!(store.len(), 1);
}
/// **provider_key correct** : test direct du contrat de persistance.
#[test]
fn p8b_provider_key_mapping_is_stable() {
assert_eq!(StructuredAdapter::Claude.provider_key(), "claude");
assert_eq!(StructuredAdapter::Codex.provider_key(), "codex");
}
/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement
/// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une
/// copie pour le prouver).
#[tokio::test]
async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, Some("engine-xyz"));
// store NON câblé (None) ⇒ aucune écriture possible.
let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None);
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch
.execute(input)
.await
.expect("launch ok without provider");
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
}
/// **No-op sans id moteur** : la session fake n'expose aucun id
/// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit.
#[tokio::test]
async fn p8b_no_engine_id_writes_nothing_launch_ok() {
let store = Arc::new(FakeProviderSessionStore::new());
// Factory avec conversation_id None ⇒ session.conversation_id() == None.
let factory = FakeFactory::new(500, None);
let (launch, agent) = launch_fixture_p8b(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch
.execute(input)
.await
.expect("launch ok without engine id");
assert_eq!(
store.len(),
0,
"no engine session id ⇒ persist skipped, launch unaffected"
);
}
/// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand
/// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent,
/// engine_session_id exposé).
#[tokio::test]
async fn p8b_store_set_error_does_not_fail_launch() {
let store = Arc::new(FakeProviderSessionStore::failing());
let factory = FakeFactory::new(500, Some("engine-xyz"));
let (launch, agent) = launch_fixture_p8b(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
// Le lancement réussit malgré l'échec d'écriture du resumable.
let out = launch
.execute(input)
.await
.expect("launch must succeed despite store set failure");
// Sortie non dégradée : descripteur structuré + id moteur toujours exposés.
let desc = out.structured.expect("structured descriptor still present");
assert_eq!(desc.session_id, sid(500));
assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz"));
// Le store n'a (logiquement) rien persisté.
assert_eq!(store.len(), 0, "failing set wrote nothing");
}
// ===========================================================================
// LOT P8c — routage du `--resume` moteur via providers.json
//
// Prouve `resolve_session_plan` (privée) **par son contrat observable** : le
// `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par
// la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le
// resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) —
// **jamais** l'id de paire lui-même n'est passé en `--resume`.
//
// Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par
// `structured_profile_with_cell_conversation_resolves_to_resume`,
// `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les
// suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store.
// ===========================================================================
/// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant
/// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`.
fn launch_fixture_p8c(
profile: AgentProfile,
factory: FakeFactory,
store: Option<Arc<FakeProviderSessionStore>>,
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
let observed = factory.clone();
let (launch, agent) = launch_fixture_p8b(profile, factory, store);
(launch, agent, observed)
}
/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
/// **et surtout PAS** l'id de paire.
#[tokio::test]
async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
let store = Arc::new(FakeProviderSessionStore::new());
// Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run.
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_p8c(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
// Pré-remplit le store : (pair, "claude") → "engine-x".
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
store.seed_sync(pair, "claude", "engine-x");
// La cellule porte l'id de **paire** (UUID), jamais l'id moteur.
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch
.execute(input)
.await
.expect("structured launch resumes");
// Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**.
let starts = observed.starts();
assert_eq!(starts.len(), 1, "factory.start called once");
assert_eq!(
starts[0].1,
SessionPlan::Resume {
conversation_id: "engine-x".to_owned()
},
"resume must carry the engine resumable id from providers.json"
);
// GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume.
match &starts[0].1 {
SessionPlan::Resume { conversation_id } => {
assert_ne!(
conversation_id,
&pair.to_string(),
"the pair id must NEVER be passed as the engine --resume"
);
}
other => panic!("expected Resume, got {other:?}"),
}
}
/// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** :
/// même contrat, clé provider `"codex"`.
#[tokio::test]
async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_p8c(
structured_codex_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
store.seed_sync(pair, "codex", "engine-codex-x");
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch
.execute(input)
.await
.expect("structured codex launch");
let starts = observed.starts();
assert_eq!(starts.len(), 1);
assert_eq!(
starts[0].1,
SessionPlan::Resume {
conversation_id: "engine-codex-x".to_owned()
},
"codex resume reads its own provider key"
);
}
/// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que
/// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve
/// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire).
#[tokio::test]
async fn p8c_provider_key_mismatch_falls_back_to_none() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_p8c(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
// Engine id rangé sous une AUTRE clé provider.
store.seed_sync(pair, "codex", "engine-codex");
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("structured launch");
let starts = observed.starts();
assert_eq!(starts.len(), 1);
assert_eq!(
starts[0].1,
SessionPlan::None,
"a claude profile must not pick up a codex-keyed resumable"
);
}
/// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de
/// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier
/// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis.
#[tokio::test]
async fn p8c_structured_store_empty_resolves_to_none() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_p8c(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
let mut input = launch_input(agent.id);
input.conversation_id = Some(pair.to_string());
launch.execute(input).await.expect("structured launch");
let starts = observed.starts();
assert_eq!(starts.len(), 1);
assert_eq!(
starts[0].1,
SessionPlan::None,
"empty store ⇒ clean first launch, never the pair id"
);
}
/// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à
/// chercher ⇒ `SessionPlan::None`.
#[tokio::test]
async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_p8c(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
// Cellule neuve : conversation_id = None.
launch
.execute(launch_input(agent.id))
.await
.expect("fresh structured launch");
let starts = observed.starts();
assert_eq!(starts.len(), 1);
assert_eq!(
starts[0].1,
SessionPlan::None,
"no cell id ⇒ nothing to look up ⇒ None"
);
}
/// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne
/// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒
/// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`.
#[tokio::test]
async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
let store = Arc::new(FakeProviderSessionStore::new());
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_p8c(
structured_profile(pid(9)),
factory,
Some(Arc::clone(&store)),
);
let mut input = launch_input(agent.id);
input.conversation_id = Some("not-a-uuid".to_owned());
launch.execute(input).await.expect("structured launch");
let starts = observed.starts();
assert_eq!(starts.len(), 1);
assert_eq!(
starts[0].1,
SessionPlan::None,
"a non-UUID pair id must degrade to None, never resume the raw string"
);
}