Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1297 lines
42 KiB
Rust
1297 lines
42 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
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.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(),
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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),
|
|
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,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"
|
|
);
|
|
}
|