Files
IdeA/crates/application/tests/change_agent_profile.rs
Blomios 2433e173a1 feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15
Cadrage Architecture §15 (figé) : « agent = entité à session persistante ».

- A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged
  (+ DTO miroir DomainEventDto camelCase).
- A1 (application) : use case ChangeAgentProfile — no-op si profil identique,
  mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts
  persistés, swap à chaud (kill PTY + relance même cellule via composition de
  LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde
  .md + mémoire, on jette l'historique de conversation).
- B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des
  cellules was_running||conversation_id, resume_supported selon profil, best-effort.

Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict.
Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8,
suite application complète verte (0 régression).

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

930 lines
29 KiB
Rust

//! 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::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PtySize, SessionId,
SessionKind, SkillId,
};
use uuid::Uuid;
use application::{
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, 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(),
},
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,
}
#[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
}
}
#[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(())
}
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
// ---------------------------------------------------------------------------
struct FakeRuntime;
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> {
Ok(SpawnSpec {
command: profile.command.clone(),
args: profile.args.clone(),
cwd: cwd.clone(),
env: Vec::new(),
context_plan: Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
})
}
}
// ---------------------------------------------------------------------------
// FakePty (PtyPort) — records spawns and kills
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FakePty {
next_id: SessionId,
spawns: Arc<Mutex<Vec<SpawnSpec>>>,
kills: Arc<Mutex<Vec<SessionId>>>,
}
impl FakePty {
fn new(next_id: SessionId) -> Self {
Self {
next_id,
spawns: Arc::new(Mutex::new(Vec::new())),
kills: Arc::new(Mutex::new(Vec::new())),
}
}
fn spawn_count(&self) -> usize {
self.spawns.lock().unwrap().len()
}
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
}
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
self.spawns.lock().unwrap().push(spec);
Ok(PtyHandle {
session_id: self.next_id,
})
}
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
Ok(())
}
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
Ok(())
}
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty()))
}
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
self.kills.lock().unwrap().push(handle.session_id);
Ok(ExitStatus { code: Some(0) })
}
}
// ---------------------------------------------------------------------------
// FakeSkills / FakeRecall / SpyBus / SeqIds
// ---------------------------------------------------------------------------
#[derive(Clone, Default)]
struct FakeSkills;
#[async_trait]
impl SkillStore for FakeSkills {
async fn list(
&self,
_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),
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)
}
/// 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);
}
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
/// Everything a change-profile test needs.
struct Fixture {
swap: ChangeAgentProfile,
contexts: FakeContexts,
fs: FakeFs,
pty: FakePty,
bus: SpyBus,
sessions: Arc<TerminalSessions>,
}
/// 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();
// 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(FakeRuntime),
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,
);
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,
}
}
fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput {
ChangeAgentProfileInput {
project: project(),
agent_id,
profile_id,
rows: 24,
cols: 80,
}
}
// ---------------------------------------------------------------------------
// 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());
}
/// Conversation cleanup: a layout cell hosting the agent with a `conversation_id`
/// and `agent_was_running = true` is reset to `(None, false)` on the persisted
/// layouts after the swap.
#[tokio::test]
async fn cleans_conversation_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 live conversation.
let leaf = nid(10);
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent.id), Some("conv-old"), true)),
);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
// The leaf's conversation id is cleared and the running flag reset.
assert_eq!(
leaf_state(&f.fs, leaf),
Some((None, false)),
"conversation_id and agent_was_running must be reset"
);
}
/// 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(mine, Some(agent.id), Some("conv-mine"), true)),
weight: 1.0,
},
domain::WeightedChild {
node: LayoutNode::Leaf(agent_leaf(
other,
Some(other_agent),
Some("conv-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");
assert_eq!(leaf_state(&f.fs, mine), Some((None, false)), "mine cleaned");
assert_eq!(
leaf_state(&f.fs, other),
Some((Some("conv-other".to_owned()), true)),
"the other agent's cell is untouched"
);
}
/// Live agent ⇒ the PTY is killed and the session is relaunched in the SAME cell
/// (node N) with a discarded conversation id (`LaunchAgent` invoked at node N).
#[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 now-foreign conversation.
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf(host, Some(agent.id), Some("conv-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"
);
}