Corrige l'incohérence P6/P7 : la cellule et la persistance partagent désormais le MÊME id de paire IdeA, déterministe et stable au redémarrage — la clé sous laquelle P6b sauve log/handoff == celle sous laquelle P7 les charge. - domaine : ConversationId::for_pair(a,b) pur/déterministe (User↔Agent = uuid agent ; Agent↔Agent = XOR commutatif) ; LeafCell gagne engine_session_id (cache resumable moteur, additif serde default) - infrastructure : InMemoryConversationRegistry::resolve utilise for_pair au lieu de new_random() → id recalculable sans état, identique après restart - application : launch_structured persiste l'id de paire sur la cellule (et l'id moteur sur engine_session_id) ; cellule neuve ⇒ dérive for_pair(User,agent) ; resolve_conversation repli factorisé sur for_pair ; withers layout clone-and- mutate (préservent les champs additifs) - app-tauri : engine_session_id propagé dans les DTO Suites domain/infra/application/app-tauri vertes (assertion structured_launch_d3 recodée sur le contrat). Test déterminisme/restart dédié à ajouter au retour de quota agent (binôme Test interrompu par limite de session). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
745 lines
24 KiB
Rust
745 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::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::markdown::MarkdownDoc;
|
|
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,
|
|
})),
|
|
fail: false,
|
|
}
|
|
}
|
|
fn failing() -> Self {
|
|
Self {
|
|
manifest: Arc::new(Mutex::new(AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
})),
|
|
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);
|
|
}
|