Files
IdeA/crates/application/tests/snapshot_running_agents.rs
Blomios 3ed0f6b45f feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

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

390 lines
12 KiB
Rust

//! T5 tests for [`SnapshotRunningAgents`].
//!
//! At close time, before the global PTY kill, the use case must freeze on each
//! agent-bearing leaf whether that agent's PTY was still live
//! (`agent_was_running`). Liveness is derived purely from the live-session
//! registry (a [`LiveAgentRegistry`]), never from CLI parsing.
//!
//! Every port is faked in-memory. We assert the persisted `layouts.json` flags
//! and the ordering guarantee (snapshot reads liveness BEFORE the kill).
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::layout::Workspace;
use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError};
use domain::{
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
ProjectPath, RemoteRef, SplitContainer, WeightedChild,
};
use uuid::Uuid;
use application::{LiveAgentRegistry, SnapshotRunningAgents, SnapshotRunningAgentsInput};
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
#[derive(Default)]
struct FakeFsInner {
files: HashMap<String, Vec<u8>>,
dirs: HashSet<String>,
}
#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);
impl FakeFs {
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
self.0.lock().unwrap().files.get(path).cloned()
}
fn put(&self, path: &str, data: &[u8]) {
self.0
.lock()
.unwrap()
.files
.insert(path.to_owned(), data.to_vec());
}
fn writes(&self) -> usize {
self.0.lock().unwrap().files.len()
}
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.0
.lock()
.unwrap()
.files
.get(path.as_str())
.cloned()
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.0
.lock()
.unwrap()
.files
.insert(path.as_str().to_owned(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
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(())
}
}
#[derive(Default)]
struct FakeStoreInner {
projects: Vec<Project>,
}
#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
#[async_trait]
impl ProjectStore for FakeStore {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(self.0.lock().unwrap().projects.clone())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
self.0
.lock()
.unwrap()
.projects
.iter()
.find(|p| p.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
self.0.lock().unwrap().projects.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())
}
}
/// A controllable liveness registry: an agent is "live" iff it is in the set.
#[derive(Default, Clone)]
struct FakeLive(Arc<Mutex<HashSet<AgentId>>>);
impl FakeLive {
fn with(agents: &[AgentId]) -> Self {
Self(Arc::new(Mutex::new(agents.iter().copied().collect())))
}
/// Simulates a PTY kill: forget every live agent.
fn kill_all(&self) {
self.0.lock().unwrap().clear();
}
}
impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.0.lock().unwrap().contains(agent_id)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ROOT: &str = "/home/me/proj";
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn lid(n: u128) -> LayoutId {
LayoutId::from_uuid(Uuid::from_u128(n))
}
fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
LeafCell {
id: node,
session: None,
agent,
conversation_id: None,
agent_was_running: false,
}
}
async fn register_project(store: &FakeStore, id: ProjectId) {
let project =
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
store.save_project(&project).await.unwrap();
}
/// Seeds a valid `layouts.json` with one 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());
}
fn doc_json(fs: &FakeFs) -> serde_json::Value {
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap()
}
/// Walks the active layout tree and returns `agent_was_running` for the leaf
/// `node`, or `None` if absent.
fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
let doc = doc_json(fs);
let tree: LayoutTree =
serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable");
fn find(node: &LayoutNode, target: NodeId) -> Option<bool> {
match node {
LayoutNode::Leaf(l) if l.id == target => Some(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)
}
fn make_use_case(
store: &FakeStore,
fs: &FakeFs,
live: &FakeLive,
) -> SnapshotRunningAgents {
SnapshotRunningAgents::new(
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
Arc::new(live.clone()) as Arc<dyn LiveAgentRegistry>,
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// A live agent's leaf is persisted with `agent_was_running = true`.
#[tokio::test]
async fn live_agent_is_marked_running() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
let live = FakeLive::with(&[agent]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 1);
assert_eq!(out.stopped, 0);
assert_eq!(was_running(&fs, leaf), Some(true));
}
/// An agent whose PTY has already exited is persisted with `false`.
#[tokio::test]
async fn stopped_agent_is_marked_not_running() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
// Registry is empty: the agent has no live session.
let live = FakeLive::default();
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 0);
assert_eq!(out.stopped, 1);
assert_eq!(was_running(&fs, leaf), Some(false));
}
/// Mixed tree (split with one live agent, one stopped agent, one plain leaf):
/// each agent leaf gets the right flag; the non-agent leaf is untouched.
#[tokio::test]
async fn mixed_tree_flags_each_agent_independently() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let live_leaf = nid(10);
let dead_leaf = nid(11);
let plain_leaf = nid(12);
let live_agent = aid(100);
let dead_agent = aid(101);
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(live_leaf, Some(live_agent))),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(dead_leaf, Some(dead_agent))),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(plain_leaf, None)),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let live = FakeLive::with(&[live_agent]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 1);
assert_eq!(out.stopped, 1);
assert_eq!(was_running(&fs, live_leaf), Some(true));
assert_eq!(was_running(&fs, dead_leaf), Some(false));
assert_eq!(was_running(&fs, plain_leaf), Some(false)); // default, untouched
}
/// Ordering guarantee: the snapshot reads liveness BEFORE the kill. Running the
/// snapshot on the live registry persists `true`; running it AFTER `kill_all`
/// (simulating a kill-then-snapshot mistake) would persist `false`.
#[tokio::test]
async fn snapshot_reads_liveness_before_kill() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
let live = FakeLive::with(&[agent]);
let uc = make_use_case(&store, &fs, &live);
// Correct order (composition root contract): snapshot THEN kill.
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
live.kill_all();
assert_eq!(
was_running(&fs, leaf),
Some(true),
"snapshot taken before kill must record running"
);
// Re-seed and demonstrate the opposite order yields `false` — proving the
// flag is sensitive to registry state at call time (hence order matters).
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(
was_running(&fs, leaf),
Some(false),
"snapshot taken after kill records not-running"
);
}
/// A layout with no agent leaf is a no-op: nothing is written.
#[tokio::test]
async fn no_agent_leaf_is_noop() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, None)));
let writes_before = fs.writes();
let live = FakeLive::default();
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 0);
assert_eq!(out.stopped, 0);
// No agent leaf → no persistence triggered (file count unchanged).
assert_eq!(fs.writes(), writes_before);
assert_eq!(was_running(&fs, leaf), Some(false));
}