//! 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>, dirs: HashSet, } #[derive(Default, Clone)] struct FakeFs(Arc>); 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, 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 { 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, 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>>); #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn load_project(&self, id: ProjectId) -> Result { 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 { Ok(Workspace::default()) } } // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) — manifest only (name + profile_id) // --------------------------------------------------------------------------- #[derive(Clone)] struct FakeContexts { manifest: Arc>, /// When true, `load_manifest` fails (best-effort path test). fail: bool, } impl FakeContexts { fn new(entries: Vec) -> 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 { 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 { 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>, fail: bool, } impl FakeProfiles { fn new(profiles: Vec) -> 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, 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 { 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, 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, Arc::new(fs.clone()) as Arc, Arc::new(contexts.clone()) as Arc, Arc::new(profiles.clone()) as Arc, ) } async fn run(uc: &ListResumableAgents) -> Vec { 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); }