Files
IdeA/crates/application/tests/snapshot_running_agents.rs
Blomios 785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

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

475 lines
15 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. Liveness is keyed on the hosting **node**
/// (the leaf), matching the snapshot's per-cell query: a leaf is "live" iff its
/// node id is in the set. An optional agent set backs the legacy
/// `is_agent_live` query (unused by the snapshot now, kept for the trait).
#[derive(Default, Clone)]
struct FakeLive {
nodes: Arc<Mutex<HashSet<NodeId>>>,
agents: Arc<Mutex<HashSet<AgentId>>>,
}
impl FakeLive {
/// Seeds the set of live **nodes** (the cells whose PTY is alive).
fn with_nodes(nodes: &[NodeId]) -> Self {
Self {
nodes: Arc::new(Mutex::new(nodes.iter().copied().collect())),
agents: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Simulates a PTY kill: forget every live node/agent.
fn kill_all(&self) {
self.nodes.lock().unwrap().clear();
self.agents.lock().unwrap().clear();
}
}
impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.agents.lock().unwrap().contains(agent_id)
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.nodes.lock().unwrap().contains(node_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_nodes(&[leaf]);
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_nodes(&[live_leaf]);
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_nodes(&[leaf]);
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));
}
/// Duplicate leaves for the **same** agent (the singleton-invariant case): only
/// the cell whose PTY is actually live is flagged running. The other leaf —
/// pinning the same agent but never launched (or refused by the guard) — stays
/// `false`. This is exactly why liveness is keyed on the node, not the agent:
/// an agent-keyed query would wrongly mark BOTH leaves as running.
#[tokio::test]
async fn duplicate_leaves_same_agent_only_live_node_is_running() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf_a = nid(10);
let leaf_b = nid(11);
let agent = aid(100); // SAME agent on both leaves
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(leaf_a, Some(agent))),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(leaf_b, Some(agent))),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
// Only node A hosts a live session.
let live = FakeLive::with_nodes(&[leaf_a]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 1, "only the live cell counts as running");
assert_eq!(
out.stopped, 1,
"the duplicate (dead) cell counts as stopped"
);
assert_eq!(was_running(&fs, leaf_a), Some(true));
assert_eq!(
was_running(&fs, leaf_b),
Some(false),
"the duplicate leaf for the same agent must NOT be marked running"
);
}