Files
IdeA/crates/application/tests/list_resumable_agents.rs
Blomios 287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

750 lines
24 KiB
Rust

//! B1 tests for [`ListResumableAgents`] (ARCHITECTURE §15.2, §15.4 line B1).
//!
//! `ListResumableAgents` is a **read-only** inventory built at project reopen:
//! it walks every persisted layout's `agent_leaves()`, reads each hosting
//! `LeafCell`'s `(conversation_id, agent_was_running)` via the pure
//! `LayoutTree::leaf` accessor, keeps only the leaves passing the §15.2 filter
//! (`was_running || conversation_id.is_some()`), resolves the display name via
//! the manifest, and derives `resume_supported` from the agent's profile
//! (`SessionStrategy` present ⇒ `true`). It is **best-effort**: any read failure
//! degrades to an empty list, never an `Err`, never a panic.
//!
//! Every port is faked in-memory (100 % without real I/O), reusing the harness
//! style of `snapshot_running_agents.rs` (FakeFs + `seed_layouts`) and
//! `change_agent_profile.rs` (FakeContexts + FakeProfiles + FakeStore).
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{
AgentId, Direction, GridCell, GridContainer, LayoutId, LayoutNode, LayoutTree, LeafCell,
NodeId, ProfileId, ProjectId, SplitContainer, WeightedChild,
};
use uuid::Uuid;
use application::{ListResumableAgents, ListResumableAgentsInput};
// ---------------------------------------------------------------------------
// FakeFs (FileSystem) — HashMap-backed, serves layouts.json
// ---------------------------------------------------------------------------
#[derive(Default)]
struct FakeFsInner {
files: HashMap<String, Vec<u8>>,
dirs: HashSet<String>,
}
#[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());
}
}
#[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(())
}
}
// ---------------------------------------------------------------------------
// FakeStore (ProjectStore) — holds the project
// ---------------------------------------------------------------------------
#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<Vec<Project>>>);
#[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())
}
}
// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore) — manifest only (name + profile_id)
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FakeContexts {
manifest: Arc<Mutex<AgentManifest>>,
/// When true, `load_manifest` fails (best-effort path test).
fail: bool,
}
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self {
Self {
manifest: Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
orchestrator: None,
})),
fail: false,
}
}
fn failing() -> Self {
Self {
manifest: Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
})),
fail: true,
}
}
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Err(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
if self.fail {
return Err(StoreError::NotFound);
}
Ok(self.manifest.lock().unwrap().clone())
}
async fn save_manifest(
&self,
_project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError> {
*self.manifest.lock().unwrap() = manifest.clone();
Ok(())
}
}
// ---------------------------------------------------------------------------
// FakeProfiles (ProfileStore) — fixed list, optionally failing on `list`
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FakeProfiles {
profiles: Arc<Vec<AgentProfile>>,
fail: bool,
}
impl FakeProfiles {
fn new(profiles: Vec<AgentProfile>) -> Self {
Self {
profiles: Arc::new(profiles),
fail: false,
}
}
fn failing() -> Self {
Self {
profiles: Arc::new(Vec::new()),
fail: true,
}
}
}
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
if self.fail {
return Err(StoreError::NotFound);
}
Ok((*self.profiles).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(())
}
}
// ---------------------------------------------------------------------------
// Helpers / 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 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()
}
/// A profile WITH a resumable `SessionStrategy` (resume_flag present).
fn profile_with_session(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"Resumable CLI",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some("claude --version".to_owned()),
"{agentRunDir}",
Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()),
)
.unwrap()
}
/// A profile WITHOUT a `SessionStrategy` (no resume support).
fn profile_no_session(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"Plain CLI",
"aider",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").unwrap(),
None,
"{agentRunDir}",
None,
)
.unwrap()
}
/// Builds a manifest entry for `agent` named `name` on profile `profile_id`.
fn entry(agent: AgentId, name: &str, profile_id: ProfileId) -> ManifestEntry {
let a = Agent::new(
agent,
name,
format!("agents/{name}.md"),
profile_id,
AgentOrigin::Scratch,
false,
)
.unwrap();
ManifestEntry::from_agent(&a)
}
/// A leaf cell hosting `agent`, optionally carrying a conversation + run 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,
}
}
/// 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());
}
/// Wires the use case over the three fakes.
fn make_use_case(
store: &FakeStore,
fs: &FakeFs,
contexts: &FakeContexts,
profiles: &FakeProfiles,
) -> ListResumableAgents {
ListResumableAgents::new(
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
Arc::new(contexts.clone()) as Arc<dyn AgentContextStore>,
Arc::new(profiles.clone()) as Arc<dyn ProfileStore>,
)
}
async fn run(uc: &ListResumableAgents) -> Vec<application::ResumableAgent> {
uc.execute(ListResumableAgentsInput { project: project() })
.await
.expect("best-effort: never errors")
.resumable
}
// ---------------------------------------------------------------------------
// 1. Correct inventory: both resumable cells appear with the right fields
// ---------------------------------------------------------------------------
/// A layout with two agent cells — one `was_running=true` (no conv), one with a
/// `conversation_id` (not running) — yields both entries, each with the right
/// `agent_id`, `name` (from manifest), `node_id`, `conversation_id`, `was_running`.
#[tokio::test]
async fn inventory_lists_both_resumable_cells() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
let running_leaf = nid(10);
let conv_leaf = nid(11);
let running_agent = aid(100);
let conv_agent = aid(101);
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(running_leaf, Some(running_agent), None, true)),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(
conv_leaf,
Some(conv_agent),
Some("conv-xyz"),
false,
)),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let contexts = FakeContexts::new(vec![
entry(running_agent, "Backend", pid(1)),
entry(conv_agent, "Tester", pid(1)),
]);
let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert_eq!(out.len(), 2, "both resumable cells listed: {out:?}");
let running = out
.iter()
.find(|r| r.agent_id == running_agent)
.expect("running agent present");
assert_eq!(running.name, "Backend");
assert_eq!(running.node_id, running_leaf);
assert_eq!(running.conversation_id, None);
assert!(running.was_running);
assert!(running.resume_supported, "profile has a SessionStrategy");
let conv = out
.iter()
.find(|r| r.agent_id == conv_agent)
.expect("conversation agent present");
assert_eq!(conv.name, "Tester");
assert_eq!(conv.node_id, conv_leaf);
assert_eq!(conv.conversation_id, Some("conv-xyz".to_owned()));
assert!(!conv.was_running);
assert!(conv.resume_supported);
}
// ---------------------------------------------------------------------------
// 2. Filter: a never-launched agent cell is excluded
// ---------------------------------------------------------------------------
/// An agent cell with neither `was_running` nor `conversation_id` is filtered out
/// (it launches normally on click, no popup), while a sibling resumable cell stays.
#[tokio::test]
async fn never_launched_cell_is_excluded() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
let fresh_leaf = nid(10);
let resumable_leaf = nid(11);
let fresh_agent = aid(100);
let resumable_agent = aid(101);
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(fresh_leaf, Some(fresh_agent), None, false)),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(
resumable_leaf,
Some(resumable_agent),
Some("c1"),
false,
)),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let contexts = FakeContexts::new(vec![
entry(fresh_agent, "Fresh", pid(1)),
entry(resumable_agent, "Resumable", pid(1)),
]);
let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert_eq!(out.len(), 1, "only the resumable cell: {out:?}");
assert_eq!(out[0].agent_id, resumable_agent);
assert!(
!out.iter().any(|r| r.agent_id == fresh_agent),
"never-launched agent must not appear"
);
}
// ---------------------------------------------------------------------------
// 3. resume_supported reflects the agent's profile
// ---------------------------------------------------------------------------
/// `resume_supported` is `true` for an agent on a profile with a SessionStrategy
/// and `false` for one without — but the latter is STILL listed (it carries a
/// `conversation_id`).
#[tokio::test]
async fn resume_supported_follows_profile() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
let supported_leaf = nid(10);
let plain_leaf = nid(11);
let supported_agent = aid(100);
let plain_agent = aid(101);
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(
supported_leaf,
Some(supported_agent),
Some("c1"),
true,
)),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(plain_leaf, Some(plain_agent), Some("c2"), true)),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let contexts = FakeContexts::new(vec![
entry(supported_agent, "Resumable", pid(1)),
entry(plain_agent, "Plain", pid(2)),
]);
let profiles = FakeProfiles::new(vec![
profile_with_session(pid(1)),
profile_no_session(pid(2)),
]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert_eq!(out.len(), 2, "both still listed: {out:?}");
let supported = out.iter().find(|r| r.agent_id == supported_agent).unwrap();
assert!(supported.resume_supported, "profile pid(1) has a session");
let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap();
assert!(
!plain.resume_supported,
"profile pid(2) has no session ⇒ resume_supported=false"
);
assert_eq!(
plain.conversation_id,
Some("c2".to_owned()),
"still listed by its conversation_id"
);
}
// ---------------------------------------------------------------------------
// 4. Best-effort / never an error
// ---------------------------------------------------------------------------
/// No `layouts.json` at all ⇒ empty inventory, `Ok` (best-effort, no panic).
#[tokio::test]
async fn missing_layouts_yields_empty_ok() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default(); // nothing seeded
let contexts = FakeContexts::new(vec![entry(aid(100), "Backend", pid(1))]);
let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert!(out.is_empty(), "no layouts ⇒ empty: {out:?}");
}
/// Failing manifest load ⇒ empty inventory, `Ok` (cannot resolve names/profiles).
#[tokio::test]
async fn failing_manifest_yields_empty_ok() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
seed_layouts(
&fs,
lid(1),
&LayoutTree::single(agent_leaf(nid(10), Some(aid(100)), Some("c1"), true)),
);
let contexts = FakeContexts::failing();
let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert!(out.is_empty(), "manifest unreadable ⇒ empty: {out:?}");
}
/// An agent present in a layout but ABSENT from the manifest is ignored — no
/// orphan entry — while a sibling agent that IS in the manifest is still listed.
#[tokio::test]
async fn agent_absent_from_manifest_is_ignored() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
let orphan_leaf = nid(10);
let known_leaf = nid(11);
let orphan_agent = aid(100); // NOT in manifest
let known_agent = aid(101); // in manifest
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(
orphan_leaf,
Some(orphan_agent),
Some("c1"),
true,
)),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(known_leaf, Some(known_agent), Some("c2"), true)),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let contexts = FakeContexts::new(vec![entry(known_agent, "Known", pid(1))]);
let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert_eq!(out.len(), 1, "orphan ignored, known listed: {out:?}");
assert_eq!(out[0].agent_id, known_agent);
assert!(
!out.iter().any(|r| r.agent_id == orphan_agent),
"orphan agent (not in manifest) must not appear"
);
}
/// A failing `ProfileStore::list` ⇒ agents are STILL listed (resumable by their
/// `conversation_id`/`was_running`), but each with `resume_supported = false`.
#[tokio::test]
async fn failing_profile_store_lists_agents_without_resume_support() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
let leaf = nid(10);
let agent = aid(100);
seed_layouts(
&fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent), Some("c1"), true)),
);
// The agent is on a profile that DOES support resume, but the profile store
// is unavailable — so resume_supported must degrade to false.
let contexts = FakeContexts::new(vec![entry(agent, "Backend", pid(1))]);
let profiles = FakeProfiles::failing();
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert_eq!(
out.len(),
1,
"agent still listed despite profile failure: {out:?}"
);
assert_eq!(out[0].agent_id, agent);
assert!(
!out[0].resume_supported,
"profiles unavailable ⇒ resume_supported=false"
);
}
// ---------------------------------------------------------------------------
// 5. Non-agent / split / grid cells produce no spurious entries
// ---------------------------------------------------------------------------
/// A mixed tree with a plain (non-agent) leaf nested in splits and grids yields
/// no parasitic entries: only the genuine resumable agent cell is reported.
#[tokio::test]
async fn non_agent_split_and_grid_cells_are_ignored() {
let store = FakeStore::default();
store.save_project(&project()).await.unwrap();
let fs = FakeFs::default();
let plain_leaf = nid(10);
let grid_plain_leaf = nid(12);
let agent_cell = nid(13);
let agent = aid(100);
// A grid holding a plain leaf and the resumable agent leaf.
let grid = LayoutNode::Grid(GridContainer {
id: nid(2),
col_weights: vec![1.0, 1.0],
row_weights: vec![1.0],
cells: vec![
GridCell {
node: LayoutNode::Leaf(agent_leaf(grid_plain_leaf, None, None, false)),
row: 0,
col: 0,
row_span: 1,
col_span: 1,
},
GridCell {
node: LayoutNode::Leaf(agent_leaf(agent_cell, Some(agent), Some("c1"), true)),
row: 0,
col: 1,
row_span: 1,
col_span: 1,
},
],
});
// A split holding a plain leaf and the grid above.
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Column,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(plain_leaf, None, None, false)),
weight: 1.0,
},
WeightedChild {
node: grid,
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let contexts = FakeContexts::new(vec![entry(agent, "Backend", pid(1))]);
let profiles = FakeProfiles::new(vec![profile_with_session(pid(1))]);
let uc = make_use_case(&store, &fs, &contexts, &profiles);
let out = run(&uc).await;
assert_eq!(out.len(), 1, "only the agent grid-cell counts: {out:?}");
assert_eq!(out[0].agent_id, agent);
assert_eq!(out[0].node_id, agent_cell);
assert!(out[0].resume_supported);
}