Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom, préférence persistée `preferred_view`, reattach live, composer + pièces jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt, cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat). Corrige le bug bloquant relevé par QA : le bouton Cancel de CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la session contrairement au contrat produit validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
751 lines
24 KiB
Rust
751 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,
|
|
preferred_view: domain::PreferredView::Tui,
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|