From 4509f0db9df1ec59a9586af04dc28be012669d7d Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 12 Jun 2026 18:52:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20P8d=20=E2=80=94=20swap=20c?= =?UTF-8?q?ross-profile=20pr=C3=A9serve=20l'id=20de=20paire=20+=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capstone du chantier handoff : un agent change de moteur (Claude↔Codex) en gardant la continuité du travail. - clean_conversation → invalidate_engine_link : préserve conversation_id (id de paire stable) et n'efface que engine_session_id (lien moteur étranger) ; renvoie l'id de paire préservé - relaunch_if_live relance avec l'id de paire (repli for_pair si session de fond) ⇒ handoff P7 réinjecté dans le nouveau moteur, resume P8c routé via providers.json[nouveau provider] (vide ⇒ SessionPlan::None : l'ancien resumable n'est jamais repassé ; fidélité par le handoff) - tests : 4 cas swap (préservation id de paire, engine_session_id vidé, handoff réinjecté, pas de --resume de l'ancien moteur, repli for_pair) ; change_agent_profile 12 verts, agrégat 829 passed 0 failed Chantier persistance conversationnelle + handoff cross-profile (P1→P8d) complet. Co-Authored-By: Claude Opus 4.8 --- crates/application/src/agent/lifecycle.rs | 83 +++- .../application/tests/change_agent_profile.rs | 393 +++++++++++++++++- 2 files changed, 440 insertions(+), 36 deletions(-) diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 24eac65..c81a64b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -469,16 +469,19 @@ impl ChangeAgentProfile { .save_manifest(&input.project, &manifest) .await?; - // 5. Clean the conversation on every persisted layout: for each leaf - // hosting this agent, drop the (now foreign) conversation id and reset - // the running flag. Mirrors `SnapshotRunningAgents`: - // resolve_doc → walk agent_leaves → mutate → persist_doc (if changed). - self.clean_conversation(&input.project, &input.agent_id) + // 5. Invalidate the engine link on every persisted layout: for each leaf + // hosting this agent, drop the (now foreign) engine resumable cache and + // reset the running flag, while **preserving** the stable IdeA pair id. + // Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves → + // mutate → persist_doc (if changed). Returns the preserved pair id. + let pair_id = self + .invalidate_engine_link(&input.project, &input.agent_id) .await?; // 6. A live session? Kill its PTY then relaunch in the same cell with the - // new profile and a discarded conversation id. - let relaunched = self.relaunch_if_live(&input).await?; + // new profile, carrying the **preserved** pair id (so the handoff is + // re-injected and resume routes via providers.json[new provider]). + let relaunched = self.relaunch_if_live(&input, pair_id).await?; // 7. Publish the profile change and return. self.events.publish(DomainEvent::AgentProfileChanged { @@ -489,28 +492,52 @@ impl ChangeAgentProfile { Ok(ChangeAgentProfileOutput { agent, relaunched }) } - /// Clears the conversation id and resets the running flag on every persisted - /// layout leaf hosting `agent_id` (step 5). Persists only when something - /// actually changed (a project with no such leaf is a no-op write). - async fn clean_conversation( + /// Invalidates the **engine link** on every persisted layout leaf hosting + /// `agent_id` (step 5), while **preserving** the IdeA pair conversation id. + /// + /// Post-P8a, `LeafCell::conversation_id` is a stable, provider-independent + /// **pair id** (User↔agent) that retrieves the work log + handoff and must + /// survive a profile swap. Only the engine-side cache + /// (`LeafCell::engine_session_id`, a foreign resumable for the new engine) is + /// cleared; the source of truth for resume routing is `providers.json`. The + /// running flag is reset too. + /// + /// Returns the **preserved pair id** of the agent (the first non-`None` leaf; + /// every leaf of this agent carries the same User↔agent pair id), or `None` + /// when no hosting leaf was found. Persists only when something actually + /// changed (a project with no such leaf is a no-op write). + async fn invalidate_engine_link( &self, project: &Project, agent_id: &AgentId, - ) -> Result<(), AppError> { + ) -> Result, AppError> { let project = self.projects.load_project(project.id).await?; let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; let mut changed = false; + let mut pair_id: Option = None; for named in &mut doc.layouts { for (leaf_id, leaf_agent) in named.tree.agent_leaves() { if &leaf_agent != agent_id { continue; } + // Capture the preserved pair id (stable across all leaves of this + // agent) — used to relaunch with handoff re-injection (P7). + if pair_id.is_none() { + if let Some(cid) = + named.tree.leaf(leaf_id).and_then(|l| l.conversation_id.clone()) + { + pair_id = Some(cid); + } + } // Pure ops — only NodeNotFound is possible, which cannot happen // since `leaf_id` came from this very tree. + // + // Preserve `conversation_id` (stable pair id); only drop the + // engine-side resumable cache (foreign to the new engine). named.tree = named .tree - .set_cell_conversation(leaf_id, None) + .set_cell_engine_session(leaf_id, None) .map_err(|e| AppError::Invalid(e.to_string()))?; named.tree = named .tree @@ -523,15 +550,24 @@ impl ChangeAgentProfile { if changed { persist_doc(self.fs.as_ref(), &project, &doc).await?; } - Ok(()) + Ok(pair_id) } /// Kills the agent's live PTY (if any) and relaunches it in the same cell with /// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the /// relaunched session, or `None` when the agent had no live session. + /// + /// `pair_id` is the **preserved** IdeA pair id (User↔agent) captured at step 5; + /// it is threaded into the relaunch so the handoff (P7) is re-injected into the + /// new engine and resume (P8c) is routed via `providers.json[new provider]` — + /// the old engine's resumable is **never** replayed (providers.json for the new + /// provider is empty ⇒ `SessionPlan::None`, fidelity carried by the handoff). + /// When step 5 found no hosting leaf (e.g. a background session without a cell), + /// the id is **derived** deterministically via `for_pair(User, agent)`. async fn relaunch_if_live( &self, input: &ChangeAgentProfileInput, + pair_id: Option, ) -> Result, AppError> { // Résolution **polymorphe** de la session vivante sur les deux registres // (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des @@ -542,6 +578,17 @@ impl ChangeAgentProfile { return Ok(None); }; + // Pair id préservé (step 5) ; en repli (session de fond sans cellule), on + // dérive l'id de paire déterministe User↔agent — les cellules sont toujours + // User↔agent, donc cette dérivation coïncide avec ce qu'eût porté la feuille. + let conversation_id = Some(pair_id.unwrap_or_else(|| { + ConversationId::for_pair( + ConversationParty::User, + ConversationParty::agent(input.agent_id), + ) + .to_string() + })); + let output = self .launch .execute(LaunchAgentInput { @@ -550,9 +597,11 @@ impl ChangeAgentProfile { rows: input.rows, cols: input.cols, node_id, - // Conversation id discarded: the previous one belonged to the old - // engine; the new profile starts (or assigns) a fresh one. - conversation_id: None, + // Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est + // réinjecté dans le nouveau moteur, et le resume (P8c) est routé via + // providers.json[nouveau provider] — l'ancien resumable n'est jamais + // repassé (providers.json du nouveau provider vide ⇒ moteur neuf). + conversation_id, // Internal relaunch (no app-tauri composition root in scope) ⇒ the // MCP runtime is not injected here; `apply_mcp_config` falls back to // the minimal declaration. A profile-hot-swap that needs the real diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 0d4ccba..83e25f0 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -301,7 +301,23 @@ impl FileSystem for FakeFs { // FakeRuntime (AgentRuntime) — minimal; only exercised on a relaunch // --------------------------------------------------------------------------- -struct FakeRuntime; +/// 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>>, +} + +impl FakeRuntime { + fn new() -> Self { + Self::default() + } + /// The last `SessionPlan` the launcher built for a (re)launch, if any. + fn last_plan(&self) -> Option { + self.plans.lock().unwrap().last().cloned() + } +} impl AgentRuntime for FakeRuntime { fn detect(&self, _profile: &AgentProfile) -> Result { @@ -312,8 +328,9 @@ impl AgentRuntime for FakeRuntime { profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, - _session: &SessionPlan, + session: &SessionPlan, ) -> Result { + self.plans.lock().unwrap().push(session.clone()); Ok(SpawnSpec { command: profile.command.clone(), args: profile.args.clone(), @@ -533,6 +550,21 @@ fn agent_leaf( } } +/// 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, + 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!({ @@ -561,6 +593,22 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option, bool)> { find(&tree.root, node) } +/// Reads back the persisted `engine_session_id` of leaf `node`. +fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option> { + 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> { + 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, @@ -580,6 +628,61 @@ fn seed_live_agent_session( 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>>); + +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, 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> { + Some(Arc::new(self.clone())) + } +} + // --------------------------------------------------------------------------- // Fixture // --------------------------------------------------------------------------- @@ -592,6 +695,10 @@ struct Fixture { pty: FakePty, bus: SpyBus, sessions: Arc, + /// 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 @@ -608,6 +715,8 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec) -> Fi 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; @@ -615,7 +724,7 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec) -> Fi let launch = LaunchAgent::new( Arc::new(contexts.clone()), Arc::new(profiles.clone()), - Arc::new(FakeRuntime), + Arc::new(runtime.clone()), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::new(FakeSkills), @@ -624,7 +733,10 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec) -> Fi 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()), @@ -644,6 +756,8 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec) -> Fi pty, bus, sessions, + runtime, + handoffs, } } @@ -757,20 +871,27 @@ async fn success_mutates_manifest_to_new_profile() { 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. +/// 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 cleans_conversation_on_persisted_layouts() { +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 live conversation. + // 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(leaf, Some(agent.id), Some("conv-old"), true)), + &LayoutTree::single(agent_leaf_with_engine( + leaf, + Some(agent.id), + Some("pair-stable"), + Some("engine-old"), + true, + )), ); f.swap @@ -778,11 +899,17 @@ async fn cleans_conversation_on_persisted_layouts() { .await .expect("swap succeeds"); - // The leaf's conversation id is cleared and the running flag reset. + // P8d: the stable pair id is PRESERVED; only the running flag is reset. assert_eq!( leaf_state(&f.fs, leaf), - Some((None, false)), - "conversation_id and agent_was_running must be reset" + 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" ); } @@ -801,14 +928,21 @@ async fn cleanup_leaves_other_agents_untouched() { direction: domain::Direction::Row, children: vec![ domain::WeightedChild { - node: LayoutNode::Leaf(agent_leaf(mine, Some(agent.id), Some("conv-mine"), true)), + 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( + node: LayoutNode::Leaf(agent_leaf_with_engine( other, Some(other_agent), Some("conv-other"), + Some("engine-other"), true, )), weight: 1.0, @@ -822,16 +956,33 @@ async fn cleanup_leaves_other_agents_untouched() { .await .expect("swap succeeds"); - assert_eq!(leaf_state(&f.fs, mine), Some((None, false)), "mine cleaned"); + // 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) with a discarded conversation id (`LaunchAgent` invoked at node N). +/// (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)); @@ -841,11 +992,17 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() { 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 a layout cell for that node carrying the stable pair id + engine cache. seed_layouts( &f.fs, lid(1), - &LayoutTree::single(agent_leaf(host, Some(agent.id), Some("conv-old"), true)), + &LayoutTree::single(agent_leaf_with_engine( + host, + Some(agent.id), + Some("pair-stable"), + Some("engine-old"), + true, + )), ); let out = f @@ -928,3 +1085,201 @@ async fn publishes_agent_profile_changed_once_on_success() { "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: `/.ideai/run//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" + ); +}