Files
IdeA/crates/application/tests/structured_launch_d3.rs
Blomios a9653bc417 fix(orchestrator): délégation inter-agent toujours headless, jamais d'injection PTY
La conversation inter-agent (idea_ask_agent) doit rester strictement
headless : la cible répond via une session structured capturée par IdeA,
sans jamais écrire dans le PTY d'une cellule visible ni fermer le terminal
que l'utilisateur observe.

- lifecycle: flag `allow_structured_alongside_pty` — ouvre une session
  structured pour la délégation sans fermer le PTY visible (coexistence).
- orchestrator/service: `ensure_structured_session` ne ferme plus le PTY
  visible ; mapping typé de l'erreur no-reply ; coexistence PTY/structured.
- infrastructure/input: garantit zéro `DelegationReady` et zéro write PTY
  pour une délégation headless, même quand l'entrée est `front_owned`.
- app-tauri (commands/state): câblage du flag de coexistence.
- tests: fixtures portées vers le modèle structured/headless, assertions
  cibles mises à jour (aucun #[ignore] ajouté, aucun test retiré).

Validé réel : cargo build OK ; orchestrator_service 60/0, agent_lifecycle
62/0, structured_launch_d3 22/0, infrastructure input 35/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:22:06 +02:00

1749 lines
60 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)
}
}
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(),
}),
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 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,
_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"
);
}