Files
IdeA/crates/application/tests/change_agent_profile.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

1848 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! A1 tests for [`ChangeAgentProfile`] (ARCHITECTURE §15.1, §15.4 line A1).
//!
//! The hot-swap of an agent's runtime profile is a *composed* use case: it mutates
//! the manifest, cleans the (now foreign) `conversation_id` / `agent_was_running`
//! on every persisted layout cell hosting the agent, and — when the agent is live —
//! kills its PTY and relaunches it in the same cell via [`LaunchAgent`].
//!
//! Every port is faked in-memory (100 % without real I/O):
//! - [`FakeContexts`] — [`AgentContextStore`] (manifest + `md_path → content`),
//! - [`FakeProfiles`] — [`ProfileStore`] returning a fixed profile list,
//! - [`FakeStore`] — [`ProjectStore`] holding the project,
//! - [`FakeFs`] — [`FileSystem`] serving/recording files (the `layouts.json` the
//! conversation-cleanup walks, plus the run-dir/convention writes of a relaunch),
//! - [`FakeRuntime`] / [`FakePty`] — the runtime + PTY, the PTY recording **kills**
//! so we can assert a live session is torn down before the relaunch,
//! - [`SpyBus`] / [`SeqIds`] / [`FakeSkills`] / [`FakeRecall`] — event spy, ids,
//! empty skill store and empty memory recall (behaviour unchanged).
//!
//! The cleanup helpers ([`seed_layouts`], [`leaf_state`]) are borrowed from the
//! `snapshot_running_agents` style: the FakeFs serves a real serialized
//! `LayoutsDoc`, so the use case's `resolve_doc → walk → persist_doc` round-trips
//! through genuine serde, exactly as in production.
use std::collections::{HashMap, HashSet};
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::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyError,
PtyHandle, PtyPort, RemotePath, 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::{
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture,
ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
};
use uuid::Uuid;
use application::{
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry,
TerminalSessions,
};
// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore) — manifest + md_path → content
// ---------------------------------------------------------------------------
#[derive(Default)]
struct ContextsInner {
manifest: AgentManifest,
contents: HashMap<String, String>,
/// Number of `save_manifest` calls observed.
saves: usize,
}
#[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(),
saves: 0,
})));
{
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
}
/// The persisted profile id currently recorded for `agent` in the manifest.
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)
}
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())
}
/// Count of `save_manifest` calls — proves a no-op path leaves the manifest
/// untouched (no mutating write).
fn manifest_saves(&self) -> usize {
self.0.lock().unwrap().saves
}
}
#[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> {
let mut inner = self.0.lock().unwrap();
inner.manifest = manifest.clone();
inner.saves += 1;
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>>>);
impl FakeStore {
async fn save(&self, project: &Project) {
self.0.lock().unwrap().push(project.clone());
}
}
#[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: serves layouts.json + records writes
// ---------------------------------------------------------------------------
#[derive(Default)]
struct FakeFsInner {
files: HashMap<String, Vec<u8>>,
dirs: HashSet<String>,
write_count: usize,
/// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions).
removed: Vec<String>,
}
#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);
impl FakeFs {
fn put(&self, path: &str, data: &[u8]) {
self.0
.lock()
.unwrap()
.files
.insert(path.to_owned(), data.to_vec());
}
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
self.0.lock().unwrap().files.get(path).cloned()
}
/// Number of `write` calls observed (used to assert a no-op path writes
/// nothing through the filesystem).
fn write_count(&self) -> usize {
self.0.lock().unwrap().write_count
}
/// Whether a file is currently present in the fake's state.
fn has_file(&self, path: &str) -> bool {
self.0.lock().unwrap().files.contains_key(path)
}
/// The ordered list of paths passed to `remove_file` (lot LP3-4).
fn removed(&self) -> Vec<String> {
self.0.lock().unwrap().removed.clone()
}
}
#[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> {
let mut inner = self.0.lock().unwrap();
inner.write_count += 1;
inner.files.insert(path.as_str().to_owned(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
let inner = self.0.lock().unwrap();
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
Ok(())
}
/// Overrides the no-op default (lot LP3-4): records the path and actually
/// removes it from the fake's state, so a deletion is assertable. Idempotent —
/// removing an absent file still succeeds (best-effort cleanup contract).
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
let mut inner = self.0.lock().unwrap();
inner.removed.push(path.as_str().to_owned());
inner.files.remove(path.as_str());
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) — minimal; only exercised on a relaunch
// ---------------------------------------------------------------------------
/// Records the [`SessionPlan`] handed to `prepare_invocation` on the (re)launch,
/// so a swap test can prove the relaunch routes a fresh `SessionPlan::None`
/// (the foreign engine resumable is **never** replayed) rather than a `Resume`.
#[derive(Clone, Default)]
struct FakeRuntime {
plans: Arc<Mutex<Vec<SessionPlan>>>,
}
impl FakeRuntime {
fn new() -> Self {
Self::default()
}
/// The last `SessionPlan` the launcher built for a (re)launch, if any.
fn last_plan(&self) -> Option<SessionPlan> {
self.plans.lock().unwrap().last().cloned()
}
}
#[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.plans.lock().unwrap().push(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()
}
/// Args of the most recent spawn (used to assert folded projection args on a
/// relaunch, lot LP3-4).
fn last_spawn_args(&self) -> Vec<String> {
self.spawns
.lock()
.unwrap()
.last()
.map(|s| s.args.clone())
.unwrap_or_default()
}
}
#[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,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(Vec::new())
}
async fn get(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<Skill, StoreError> {
Err(StoreError::NotFound)
}
async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
Ok(())
}
async fn delete(
&self,
_scope: SkillScope,
_root: &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
}
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
const ROOT: &str = "/home/me/proj";
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
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 lid(n: u128) -> LayoutId {
LayoutId::from_uuid(Uuid::from_u128(n))
}
fn proj_id(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
proj_id(1000),
"demo",
ProjectPath::new(ROOT).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn profile(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"Some CLI",
"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()
}
/// A leaf cell hosting `agent`, optionally carrying a conversation + running flag.
fn agent_leaf(
node: NodeId,
agent: Option<AgentId>,
conversation_id: Option<&str>,
agent_was_running: bool,
) -> LeafCell {
LeafCell {
id: node,
session: None,
agent,
conversation_id: conversation_id.map(str::to_owned),
engine_session_id: None,
agent_was_running,
}
}
/// Like [`agent_leaf`], additionally seeding an `engine_session_id` (the engine
/// resumable cache) so we can assert it is invalidated on a profile swap.
fn agent_leaf_with_engine(
node: NodeId,
agent: Option<AgentId>,
conversation_id: Option<&str>,
engine_session_id: Option<&str>,
agent_was_running: bool,
) -> LeafCell {
LeafCell {
engine_session_id: engine_session_id.map(str::to_owned),
..agent_leaf(node, agent, conversation_id, agent_was_running)
}
}
/// Seeds a valid `layouts.json` (a single active layout holding `tree`).
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
let doc = serde_json::json!({
"version": 1,
"activeId": id.to_string(),
"layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
});
fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
}
/// Reads back the persisted `(conversation_id, agent_was_running)` of leaf `node`.
fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
let bytes = fs.read_file(LAYOUTS_PATH)?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap();
fn find(n: &LayoutNode, target: NodeId) -> Option<(Option<String>, bool)> {
match n {
LayoutNode::Leaf(l) if l.id == target => {
Some((l.conversation_id.clone(), l.agent_was_running))
}
LayoutNode::Leaf(_) => None,
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
}
}
find(&tree.root, node)
}
/// Reads back the persisted `engine_session_id` of leaf `node`.
fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option<Option<String>> {
let bytes = fs.read_file(LAYOUTS_PATH)?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap();
fn find(n: &LayoutNode, target: NodeId) -> Option<Option<String>> {
match n {
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
}
}
find(&tree.root, node)
}
/// Seeds a live agent session pinned on `node` into the registry.
fn seed_live_agent_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);
}
// ---------------------------------------------------------------------------
// FakeHandoffs (HandoffProvider) — root-scoped store seeded with one handoff
// ---------------------------------------------------------------------------
/// A [`HandoffProvider`] backed by an in-memory [`HandoffStore`] keyed by pair id.
/// Wiring this into the relaunch's `LaunchAgent` lets a test prove the **threaded
/// pair id** end-to-end: the `# Reprise de la conversation` section appears in the
/// relaunch's convention file **only if** the relaunch carried the exact
/// `conversation_id` under which the handoff was seeded.
#[derive(Clone, Default)]
struct FakeHandoffs(Arc<Mutex<HashMap<Uuid, domain::Handoff>>>);
impl FakeHandoffs {
/// Seeds a handoff under conversation id `conv` (a UUID-shaped pair id string).
fn seed(&self, conv: &str, summary: &str, objective: Option<&str>) {
let uuid = Uuid::parse_str(conv).expect("seed conv id must be a UUID");
let handoff = domain::Handoff::new(
summary.to_owned(),
domain::TurnId::from_uuid(Uuid::from_u128(0xABCD)),
objective.map(str::to_owned),
);
self.0.lock().unwrap().insert(uuid, handoff);
}
}
#[async_trait]
impl domain::HandoffStore for FakeHandoffs {
async fn load(
&self,
conversation: domain::ConversationId,
) -> Result<Option<domain::Handoff>, StoreError> {
Ok(self.0.lock().unwrap().get(&conversation.as_uuid()).cloned())
}
async fn save(
&self,
conversation: domain::ConversationId,
handoff: domain::Handoff,
) -> Result<(), StoreError> {
self.0
.lock()
.unwrap()
.insert(conversation.as_uuid(), handoff);
Ok(())
}
}
impl application::HandoffProvider for FakeHandoffs {
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn domain::HandoffStore>> {
Some(Arc::new(self.clone()))
}
}
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
/// Everything a change-profile test needs.
struct Fixture {
swap: ChangeAgentProfile,
contexts: FakeContexts,
fs: FakeFs,
pty: FakePty,
bus: SpyBus,
sessions: Arc<TerminalSessions>,
/// The runtime used by the composed relaunch — records the `SessionPlan`.
runtime: FakeRuntime,
/// The handoff store wired into the relaunch (seed via [`FakeHandoffs::seed`]).
handoffs: FakeHandoffs,
}
/// Wires a [`ChangeAgentProfile`] over fakes. The agent starts on `pid(1)`; the
/// profile store knows both `pid(1)` and `pid(2)` (the valid swap target).
async fn fixture(agent: &Agent) -> Fixture {
fixture_with_profiles(agent, vec![profile(pid(1)), profile(pid(2))]).await
}
async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fixture {
let contexts = FakeContexts::with_agent(agent, "# persona");
let profiles = FakeProfiles::new(profiles);
let store = FakeStore::default();
let fs = FakeFs::default();
let pty = FakePty::new(sid(777));
let sessions = Arc::new(TerminalSessions::new());
let bus = SpyBus::default();
let runtime = FakeRuntime::new();
let handoffs = FakeHandoffs::default();
// Register the project so ProjectStore::load_project resolves.
store.save(&project()).await;
let launch = LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::new(profiles.clone()),
Arc::new(runtime.clone()),
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,
)
// Wire the handoff provider so a relaunch re-injects the (pair-keyed) handoff
// into the new engine's convention file — the end-to-end proof of P7+P8d.
.with_handoff_provider(Arc::new(handoffs.clone()));
let swap = ChangeAgentProfile::new(
Arc::new(contexts.clone()),
Arc::new(profiles),
Arc::new(store),
Arc::new(fs.clone()),
Arc::clone(&sessions),
Arc::new(pty.clone()),
Arc::new(launch),
Arc::new(bus.clone()),
);
Fixture {
swap,
contexts,
fs,
pty,
bus,
sessions,
runtime,
handoffs,
}
}
fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput {
ChangeAgentProfileInput {
project: project(),
agent_id,
profile_id,
rows: 24,
cols: 80,
}
}
// ---------------------------------------------------------------------------
// LP3-4 — permission-file cleanup at swap: fakes, projectors, fixture
// ---------------------------------------------------------------------------
/// In-memory [`PermissionStore`] returning a fixed document (so the relaunch's
/// projection resolves a non-empty `eff` and actually writes the new CLI config).
struct FakePermissionStore(ProjectPermissions);
#[async_trait]
impl PermissionStore for FakePermissionStore {
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
Ok(self.0.clone())
}
async fn save_permissions(
&self,
_project: &Project,
_permissions: &ProjectPermissions,
) -> Result<(), StoreError> {
Ok(())
}
}
/// Faithful Claude projector double (LP3-2 mapping): owns the `Replace` settings
/// seed `.claude/settings.local.json`.
struct FakeClaudeProjector;
impl PermissionProjector for FakeClaudeProjector {
fn key(&self) -> ProjectorKey {
ProjectorKey::Claude
}
fn project(
&self,
eff: Option<&EffectivePermissions>,
ctx: &ProjectionContext,
) -> PermissionProjection {
if eff.is_none() {
return PermissionProjection::empty();
}
let contents = format!(
"{{\"permissions\":{{\"additionalDirectories\":[\"{}\"]}}}}\n",
ctx.project_root
);
PermissionProjection {
files: vec![ProjectedFile::Replace {
rel_path: ".claude/settings.local.json".to_owned(),
contents,
}],
args: Vec::new(),
env: Vec::new(),
}
}
fn owned_replace_paths(&self) -> Vec<String> {
vec![".claude/settings.local.json".to_owned()]
}
}
/// Faithful Codex projector double (LP3-2 mapping): a co-owned `MergeToml` + the
/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file.
struct FakeCodexProjector;
impl PermissionProjector for FakeCodexProjector {
fn key(&self) -> ProjectorKey {
ProjectorKey::Codex
}
fn project(
&self,
eff: Option<&EffectivePermissions>,
_ctx: &ProjectionContext,
) -> PermissionProjection {
if eff.is_none() {
return PermissionProjection::empty();
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
managed_tables: Vec::new(),
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"
.to_owned(),
}],
args: vec![
"--sandbox".to_owned(),
"workspace-write".to_owned(),
"--ask-for-approval".to_owned(),
"never".to_owned(),
],
env: Vec::new(),
}
}
fn owned_replace_paths(&self) -> Vec<String> {
Vec::new()
}
}
/// A registry carrying both faithful projector doubles.
fn full_registry() -> Arc<PermissionProjectorRegistry> {
Arc::new(
PermissionProjectorRegistry::new()
.with(Arc::new(FakeClaudeProjector))
.with(Arc::new(FakeCodexProjector)),
)
}
/// A Claude profile (convention `CLAUDE.md` ⇒ legacy Claude selection).
fn claude_profile(id: ProfileId) -> AgentProfile {
profile(id)
}
/// A Codex profile carrying an explicit `ProjectorKey::Codex` (and the Codex
/// structured adapter, so the legacy fallback would also select Codex).
fn codex_profile(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"OpenAI Codex CLI",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").unwrap(),
Some("codex --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
}
/// The (stable) run dir of `agent` under the test project root.
fn run_dir_of(agent: &AgentId) -> String {
format!("{ROOT}/.ideai/run/{agent}")
}
/// Wires a swap fixture with a projector registry on BOTH the swap and the
/// composed relaunch, plus an optional permission document on the relaunch (so
/// the relaunch's projection is non-empty). Mirrors [`fixture_with_profiles`].
async fn fixture_with_projection(
agent: &Agent,
profiles: Vec<AgentProfile>,
registry: Arc<PermissionProjectorRegistry>,
perm_doc: Option<ProjectPermissions>,
) -> Fixture {
let contexts = FakeContexts::with_agent(agent, "# persona");
let profiles = FakeProfiles::new(profiles);
let store = FakeStore::default();
let fs = FakeFs::default();
let pty = FakePty::new(sid(777));
let sessions = Arc::new(TerminalSessions::new());
let bus = SpyBus::default();
let runtime = FakeRuntime::new();
let handoffs = FakeHandoffs::default();
store.save(&project()).await;
let mut launch = LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::new(profiles.clone()),
Arc::new(runtime.clone()),
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_handoff_provider(Arc::new(handoffs.clone()))
.with_permission_projectors(Arc::clone(&registry));
if let Some(doc) = perm_doc {
launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
}
let swap = ChangeAgentProfile::new(
Arc::new(contexts.clone()),
Arc::new(profiles),
Arc::new(store),
Arc::new(fs.clone()),
Arc::clone(&sessions),
Arc::new(pty.clone()),
Arc::new(launch),
Arc::new(bus.clone()),
)
.with_permission_projectors(Arc::clone(&registry));
Fixture {
swap,
contexts,
fs,
pty,
bus,
sessions,
runtime,
handoffs,
}
}
/// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch
/// resolves `Some(eff)` and the projector writes the new CLI config.
fn allow_perm_doc() -> ProjectPermissions {
ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new())
}
const CLAUDE_SEED_REL: &str = ".claude/settings.local.json";
const CODEX_CONFIG_REL: &str = ".codex/config.toml";
/// Seeds a live agent session + a persisted layout cell hosting it, so the swap
/// reaches its relaunch step (kill → relaunch in the same cell).
fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) {
let host = nid(1);
seed_live_agent_session(&f.sessions, *agent, host, sid(42));
let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true));
seed_layouts(&f.fs, lid(1), &tree);
}
/// A stable UUID-shaped pair id used by the relaunch scenarios.
fn pair_uuid() -> String {
Uuid::from_u128(0xABCD).to_string()
}
// ===========================================================================
// LP3-4 — cleanup of orphan permission files at a cross-profile swap
// ===========================================================================
/// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json`
/// is removed at the swap, and the relaunch projects the Codex sandbox config +
/// args. The orphan Replace file of the old projector is cleaned up; the new
/// projector (Codex) owns no Replace file, so nothing protects it from deletion.
#[tokio::test]
async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), codex_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let run_dir = run_dir_of(&agent.id);
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
// The old Claude seed exists in the (stable) run dir before the swap.
f.fs.put(&seed_path, b"{\"permissions\":{}}");
seed_live_for_relaunch(&f, &agent.id);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// The orphan Claude seed was deleted (recorded AND gone from the FS state).
assert!(
f.fs.removed().iter().any(|p| p == &seed_path),
"the orphan .claude seed must be removed; removed={:?}",
f.fs.removed()
);
assert!(
!f.fs.has_file(&seed_path),
"the seed is gone from the FS state"
);
// The relaunch projected the Codex sandbox config + args.
assert!(
f.fs.has_file(&codex_path),
"the relaunch must project the Codex config"
);
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
assert!(
toml.contains("sandbox_mode = \"workspace-write\""),
"{toml}"
);
assert!(
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
"sandbox args folded into the relaunch spawn: {:?}",
f.pty.last_spawn_args()
);
}
/// (2) **Claude→Claude**: `owned(old) owned(new)` is empty, so the seed is NOT
/// removed (it is re-clobbered by the relaunch, never deleted).
#[tokio::test]
async fn swap_claude_to_claude_does_not_remove_seed() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), claude_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
seed_live_for_relaunch(&f, &agent.id);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert!(
!f.fs.removed().iter().any(|p| p == &seed_path),
"same-family swap must NOT delete the shared seed; removed={:?}",
f.fs.removed()
);
// It is still present (re-clobbered by the relaunch's projection).
assert!(
f.fs.has_file(&seed_path),
"the seed survives the same-family swap"
);
}
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
/// cleanup side; the relaunch writes the Claude seed; the co-owned
/// `.codex/config.toml` is never deleted.
#[tokio::test]
async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![codex_profile(pid(1)), claude_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let run_dir = run_dir_of(&agent.id);
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
// A pre-existing co-owned Codex config that must survive the swap.
f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n");
seed_live_for_relaunch(&f, &agent.id);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// Nothing removed (Codex has no Replace-owned path).
assert!(
f.fs.removed().is_empty(),
"Codex→Claude removes no permission file; removed={:?}",
f.fs.removed()
);
// The co-owned Codex config is untouched by cleanup…
assert!(
f.fs.has_file(&codex_path),
"the .codex/config.toml is never deleted"
);
assert!(
!f.fs.removed().iter().any(|p| p == &codex_path),
"the .codex/config.toml is not in the removed list"
);
// …and the relaunch projected the new Claude seed.
assert!(
f.fs.has_file(&seed_path),
"the relaunch writes the Claude seed"
);
}
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
/// no cleanup runs at all — even on a Claude→Codex swap with the seed present.
#[tokio::test]
async fn swap_without_registry_removes_nothing() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
// The default fixture wires NO projector registry on the swap.
let f =
fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert!(
f.fs.removed().is_empty(),
"no registry ⇒ no cleanup; removed={:?}",
f.fs.removed()
);
assert!(f.fs.has_file(&seed_path), "the seed is left untouched");
}
/// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer
/// in the store, the cleanup is skipped (the old projector can't be resolved) and
/// the swap still succeeds.
#[tokio::test]
async fn swap_with_unknown_previous_profile_skips_cleanup() {
// Agent records pid(1), but the store only knows pid(2) (the target) and pid(3):
// pid(1) was deleted between launch and swap.
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![codex_profile(pid(2)), claude_profile(pid(3))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds even with an unknown previous profile");
assert!(
f.fs.removed().is_empty(),
"unknown previous profile ⇒ cleanup skipped; removed={:?}",
f.fs.removed()
);
}
/// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the
/// delete is still attempted (idempotent) and the swap succeeds without error.
#[tokio::test]
async fn swap_claude_to_codex_succeeds_when_seed_absent() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), codex_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
// Intentionally do NOT seed `.claude/settings.local.json`.
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds even when the orphan file is already gone");
// The delete was attempted (best-effort, idempotent) on the missing path.
assert!(
f.fs.removed().iter().any(|p| p == &seed_path),
"a remove was still attempted on the absent seed; removed={:?}",
f.fs.removed()
);
}
/// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id.
/// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still
/// re-injects that handoff into the new engine's convention file (proving the
/// pair `conversation_id` survived the cleanup step unchanged).
#[tokio::test]
async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture_with_projection(
&agent,
vec![claude_profile(pid(1)), codex_profile(pid(2))],
full_registry(),
Some(allow_perm_doc()),
)
.await;
// Seed the orphan Claude seed (so cleanup actually fires) and a handoff under
// the leaf's pair id.
let run_dir = run_dir_of(&agent.id);
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
let pair = pair_uuid();
f.handoffs
.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
let host = nid(1);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true));
seed_layouts(&f.fs, lid(1), &tree);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// The cleanup fired…
assert!(
f.fs.removed()
.iter()
.any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")),
"the orphan seed was cleaned up"
);
// …and the pair id was preserved on the persisted leaf (P8d invariant).
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
assert!(
!running,
"engine running flag reset by invalidate_engine_link"
);
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
// convention file — proving the relaunch threaded the preserved pair id. (The
// shared FakeRuntime always materialises the convention file as `CLAUDE.md`.)
let conv_md = String::from_utf8(
f.fs.read_file(&format!("{run_dir}/CLAUDE.md"))
.expect("the relaunch wrote the new engine's convention file"),
)
.unwrap();
assert!(
conv_md.contains("LP3-4"),
"handoff re-injected under the preserved pair id: {conv_md}"
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// No-op: swapping to the *same* profile leaves the agent unchanged, relaunches
/// nothing, publishes no event, kills nothing, and writes no manifest/layout.
#[tokio::test]
async fn same_profile_is_noop() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
let out = f
.swap
.execute(change_input(agent.id, pid(1)))
.await
.expect("no-op succeeds");
// Agent returned unchanged, no relaunch.
assert_eq!(out.agent.profile_id, pid(1));
assert!(out.relaunched.is_none(), "no relaunch on a no-op");
// No event published.
assert!(f.bus.events().is_empty(), "no-op publishes no event");
// No kill, no spawn.
assert!(f.pty.kills().is_empty(), "no-op kills nothing");
assert_eq!(f.pty.spawn_count(), 0, "no-op spawns nothing");
// Manifest never re-saved (no observable mutation).
assert_eq!(
f.contexts.manifest_saves(),
0,
"no-op must not rewrite the manifest"
);
// No filesystem write at all.
assert_eq!(f.fs.write_count(), 0, "no-op must not write any layout");
// Persisted profile is still the original.
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1)));
}
/// Unknown target profile ⇒ NotFound, and the agent is **not** mutated (the
/// manifest keeps the original profile id).
#[tokio::test]
async fn unknown_profile_is_not_found_and_does_not_mutate() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// pid(99) is not in the store (only pid(1), pid(2)).
let err = f
.swap
.execute(change_input(agent.id, pid(99)))
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
// Manifest unchanged.
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1)));
assert_eq!(f.contexts.manifest_saves(), 0);
assert!(f.pty.kills().is_empty());
assert!(f.bus.events().is_empty());
}
/// Unknown agent ⇒ NotFound.
#[tokio::test]
async fn unknown_agent_is_not_found() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
let err = f
.swap
.execute(change_input(aid(404), pid(2)))
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
assert_eq!(f.contexts.manifest_saves(), 0);
assert!(f.bus.events().is_empty());
}
/// Success: the persisted manifest carries the **new** profile id, and the
/// returned agent reflects it.
#[tokio::test]
async fn success_mutates_manifest_to_new_profile() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert_eq!(
out.agent.profile_id,
pid(2),
"returned agent carries new profile"
);
assert_eq!(
f.contexts.profile_of(&agent.id),
Some(pid(2)),
"manifest persisted with the new profile"
);
assert_eq!(f.contexts.manifest_saves(), 1, "exactly one manifest save");
// Dead agent (no live session) ⇒ no relaunch.
assert!(out.relaunched.is_none());
}
/// Engine-link invalidation (P8d new contract): a layout cell hosting the agent
/// **preserves** its stable pair `conversation_id`, **clears** the foreign engine
/// resumable cache (`engine_session_id`) and resets `agent_was_running` on the
/// persisted layouts after the swap.
#[tokio::test]
async fn invalidates_engine_link_preserving_pair_id_on_persisted_layouts() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// Seed a layout: leaf nid(10) hosts the agent with a pair id + engine cache.
let leaf = nid(10);
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf_with_engine(
leaf,
Some(agent.id),
Some("pair-stable"),
Some("engine-old"),
true,
)),
);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// P8d: the stable pair id is PRESERVED; only the running flag is reset.
assert_eq!(
leaf_state(&f.fs, leaf),
Some((Some("pair-stable".to_owned()), false)),
"pair conversation_id must be preserved; agent_was_running reset"
);
// The foreign engine resumable cache is invalidated.
assert_eq!(
leaf_engine_session(&f.fs, leaf),
Some(None),
"engine_session_id must be cleared"
);
}
/// Cleanup leaves foreign agents untouched: a second agent's cell keeps its own
/// conversation id.
#[tokio::test]
async fn cleanup_leaves_other_agents_untouched() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
let mine = nid(10);
let other = nid(11);
let other_agent = aid(2);
let tree = LayoutTree::new(LayoutNode::Split(domain::SplitContainer {
id: nid(1),
direction: domain::Direction::Row,
children: vec![
domain::WeightedChild {
node: LayoutNode::Leaf(agent_leaf_with_engine(
mine,
Some(agent.id),
Some("conv-mine"),
Some("engine-mine"),
true,
)),
weight: 1.0,
},
domain::WeightedChild {
node: LayoutNode::Leaf(agent_leaf_with_engine(
other,
Some(other_agent),
Some("conv-other"),
Some("engine-other"),
true,
)),
weight: 1.0,
},
],
}));
seed_layouts(&f.fs, lid(1), &tree);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// P8d: my pair id is preserved; running reset; engine cache cleared.
assert_eq!(
leaf_state(&f.fs, mine),
Some((Some("conv-mine".to_owned()), false)),
"mine: pair id preserved, running reset"
);
assert_eq!(
leaf_engine_session(&f.fs, mine),
Some(None),
"mine: engine_session_id cleared"
);
// The other agent's cell is entirely untouched (pair id, engine, running).
assert_eq!(
leaf_state(&f.fs, other),
Some((Some("conv-other".to_owned()), true)),
"the other agent's cell is untouched"
);
assert_eq!(
leaf_engine_session(&f.fs, other),
Some(Some("engine-other".to_owned())),
"the other agent's engine cache is untouched"
);
}
/// Live agent ⇒ the PTY is killed and the session is relaunched in the SAME cell
/// (node N). Post-P8d the stable pair id is **preserved** and threaded into the
/// relaunch (`LaunchAgent` invoked at node N); the engine cache is invalidated.
#[tokio::test]
async fn live_agent_is_killed_and_relaunched_in_same_cell() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// The agent is live in cell N, session sid(42).
let host = nid(5);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
// Seed a layout cell for that node carrying the stable pair id + engine cache.
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf_with_engine(
host,
Some(agent.id),
Some("pair-stable"),
Some("engine-old"),
true,
)),
);
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("hot swap succeeds");
// The old PTY was killed.
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
// Exactly one relaunch spawn.
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
// The relaunched session is returned and pinned on the SAME cell N.
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), "relaunch adopts the new PTY id");
// The relaunched session is registered and tagged for this agent.
assert!(matches!(
relaunched.kind,
SessionKind::Agent { agent_id } if agent_id == agent.id
));
assert_eq!(
f.sessions.session_for_agent(&agent.id),
Some(sid(777)),
"the registry now holds the relaunched session"
);
// Manifest carries the new profile.
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}
/// Dead agent (no live session) ⇒ no kill, no relaunch; the manifest is still
/// mutated to the new profile.
#[tokio::test]
async fn dead_agent_mutates_without_relaunch() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// No live session seeded.
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert!(out.relaunched.is_none(), "no relaunch for a dead agent");
assert!(f.pty.kills().is_empty(), "nothing to kill");
assert_eq!(f.pty.spawn_count(), 0, "no spawn for a dead agent");
// Manifest still mutated.
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}
/// Event: a successful mutating swap publishes `AgentProfileChanged` exactly once,
/// carrying the agent id and the NEW profile id.
#[tokio::test]
async fn publishes_agent_profile_changed_once_on_success() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
let profile_changed: Vec<_> = f
.bus
.events()
.into_iter()
.filter(|e| {
matches!(
e,
DomainEvent::AgentProfileChanged { agent_id, profile_id }
if *agent_id == agent.id && *profile_id == pid(2)
)
})
.collect();
assert_eq!(
profile_changed.len(),
1,
"AgentProfileChanged published exactly once with the new profile"
);
}
// ---------------------------------------------------------------------------
// P8d capstone — cross-profile swap threads the PRESERVED pair id into the
// relaunch, never replays the old engine resumable, and re-injects the handoff.
// ---------------------------------------------------------------------------
/// The relaunch's convention file path: `<root>/.ideai/run/<agent-id>/CLAUDE.md`
/// (the `FakeRuntime` plan targets `CLAUDE.md`, written into the agent run dir).
fn relaunch_convention_path(agent: &AgentId) -> String {
format!("{ROOT}/.ideai/run/{agent}/CLAUDE.md")
}
/// Case 2 — live agent, swap relaunches threading the **preserved pair id** and a
/// **fresh** `SessionPlan::None` (the old engine resumable is NEVER replayed).
///
/// The pair id on the leaf is a real UUID under which a handoff is seeded. After
/// the swap the new engine's convention file carries `# Reprise de la conversation`
/// — provable only if the relaunch's `conversation_id` equalled that exact pair id.
/// And the `SessionPlan` built for the relaunch is `None`, never `Resume{engine}`.
#[tokio::test]
async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff.
let pair = "11111111-1111-1111-1111-111111111111";
f.handoffs.seed(
pair,
"Résumé : on a fini l'étape 2.",
Some("Livrer le lot P8d"),
);
// Live agent on cell N with a foreign engine resumable cache.
let host = nid(5);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf_with_engine(
host,
Some(agent.id),
Some(pair),
Some("engine-old"),
true,
)),
);
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("hot swap succeeds");
// The old PTY was killed and a single relaunch happened in the SAME cell.
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
let relaunched = out.relaunched.expect("a live agent is relaunched");
assert_eq!(
relaunched.node_id, host,
"relaunch reopens in the same cell"
);
// The pair id is preserved on the persisted leaf; the engine cache is cleared.
assert_eq!(
leaf_state(&f.fs, host),
Some((Some(pair.to_owned()), false)),
"pair id preserved, running reset"
);
assert_eq!(
leaf_engine_session(&f.fs, host),
Some(None),
"engine_session_id cleared"
);
// The relaunch threaded the PRESERVED pair id: the handoff seeded under that
// exact id is re-injected into the new engine's convention file.
let conv = String::from_utf8(
f.fs.read_file(&relaunch_convention_path(&agent.id))
.expect("relaunch wrote a convention file"),
)
.unwrap();
assert!(
conv.contains("# Reprise de la conversation"),
"relaunch must re-inject the handoff under the preserved pair id: {conv}"
);
assert!(
conv.contains("Résumé : on a fini l'étape 2."),
"handoff summary present in the new engine's convention file: {conv}"
);
// The old engine resumable is NEVER replayed: the relaunch's SessionPlan is
// None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried
// by the handoff). It is emphatically NOT Resume{"engine-old"}.
let plan = f
.runtime
.last_plan()
.expect("the relaunch prepared an invocation");
assert_eq!(
plan,
SessionPlan::None,
"the foreign engine resumable must never be replayed on a swap"
);
}
/// Case 3 — live agent with NO hosting cell (background session ⇒ step 5 returns
/// `None`). The relaunch must derive the pair id deterministically via
/// `ConversationId::for_pair(User, agent)` (== the agent's own UUID).
///
/// Proven end-to-end: a handoff seeded under `for_pair(User, agent)` is re-injected
/// into the relaunch's convention file, which can only happen if the relaunch
/// threaded that derived id as its `conversation_id`.
#[tokio::test]
async fn live_swap_without_cell_relaunches_with_for_pair_id() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// The deterministic User↔agent pair id equals the agent's own UUID.
let derived = domain::ConversationId::for_pair(
domain::ConversationParty::User,
domain::ConversationParty::agent(agent.id),
)
.to_string();
assert_eq!(
derived,
agent.id.to_string(),
"for_pair(User, agent) == agent uuid (sanity)"
);
f.handoffs
.seed(&derived, "Repris depuis une session de fond.", None);
// Live agent but NO seeded layout cell ⇒ node_for_agent yields None
// (background session) ⇒ step 5 finds no hosting leaf ⇒ pair_id None.
let host = nid(5);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
// Intentionally NO seed_layouts: there is no persisted hosting cell.
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("hot swap succeeds");
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
assert!(out.relaunched.is_some(), "a live agent is relaunched");
// The relaunch derived the pair id via for_pair: the handoff seeded under it is
// re-injected into the convention file (proves the threaded conversation id).
let conv = String::from_utf8(
f.fs.read_file(&relaunch_convention_path(&agent.id))
.expect("relaunch wrote a convention file"),
)
.unwrap();
assert!(
conv.contains("# Reprise de la conversation"),
"relaunch must use for_pair(User, agent) as the conversation id: {conv}"
);
assert!(
conv.contains("Repris depuis une session de fond."),
"handoff (keyed by for_pair) re-injected: {conv}"
);
// Still no engine resume: a swap never replays a foreign resumable.
assert_eq!(
f.runtime.last_plan(),
Some(SessionPlan::None),
"no engine resume on a swap (for_pair path)"
);
}
/// Case 1 (completeness) — preservation + invalidation read straight off the
/// persisted leaf with a **UUID** pair id (the realistic post-P8a shape), pairing
/// the existing `pair-stable` opaque-string test. The pair id survives untouched;
/// the engine cache is cleared; the running flag is reset.
#[tokio::test]
async fn swap_preserves_uuid_pair_id_and_clears_engine_cache() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
let leaf = nid(10);
let pair = "22222222-2222-2222-2222-222222222222";
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf_with_engine(
leaf,
Some(agent.id),
Some(pair),
Some("engine-old"),
true,
)),
);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert_eq!(
leaf_state(&f.fs, leaf),
Some((Some(pair.to_owned()), false)),
"UUID pair id preserved; agent_was_running reset"
);
assert_eq!(
leaf_engine_session(&f.fs, leaf),
Some(None),
"engine_session_id cleared on swap"
);
}