feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15
Cadrage Architecture §15 (figé) : « agent = entité à session persistante ». - A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged (+ DTO miroir DomainEventDto camelCase). - A1 (application) : use case ChangeAgentProfile — no-op si profil identique, mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts persistés, swap à chaud (kill PTY + relance même cellule via composition de LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde .md + mémoire, on jette l'historique de conversation). - B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des cellules was_running||conversation_id, resume_supported selon profil, best-effort. Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict. Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8, suite application complète verte (0 régression). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
743
crates/application/tests/list_resumable_agents.rs
Normal file
743
crates/application/tests/list_resumable_agents.rs
Normal file
@ -0,0 +1,743 @@
|
||||
//! 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),
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user