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>
351 lines
11 KiB
Rust
351 lines
11 KiB
Rust
//! R0c tests for [`ReconcileLayouts`].
|
|
//!
|
|
//! At project open, a persisted `layouts.json` may already hold several leaves
|
|
//! pinning the **same** agent id. The use case de-duplicates them through the
|
|
//! pure domain op [`domain::LayoutTree::reconcile_duplicate_agents`] and
|
|
//! persists the doc **only** when something changed (idempotent no-op
|
|
//! otherwise).
|
|
//!
|
|
//! Harness mirrors `tests/snapshot_running_agents.rs` (its twin use case): the
|
|
//! same in-memory `FakeFs` / `FakeStore`. The fake FS additionally counts the
|
|
//! number of `write` **calls** so test 7 can prove that a no-op performs zero
|
|
//! writes (file-count alone cannot, since `layouts.json` already exists).
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::layout::Workspace;
|
|
use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError};
|
|
use domain::{
|
|
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
|
ProjectPath, RemoteRef, SplitContainer, WeightedChild,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use application::{ReconcileLayouts, ReconcileLayoutsInput};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct FakeFsInner {
|
|
files: HashMap<String, Vec<u8>>,
|
|
dirs: HashSet<String>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct FakeFs {
|
|
inner: Arc<Mutex<FakeFsInner>>,
|
|
/// Count of `write` **calls** (not files). Lets a no-op be asserted even
|
|
/// though the target file already exists.
|
|
write_calls: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl FakeFs {
|
|
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
|
self.inner.lock().unwrap().files.get(path).cloned()
|
|
}
|
|
fn put(&self, path: &str, data: &[u8]) {
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.files
|
|
.insert(path.to_owned(), data.to_vec());
|
|
}
|
|
fn write_calls(&self) -> usize {
|
|
self.write_calls.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FileSystem for FakeFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
|
self.inner
|
|
.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.write_calls.fetch_add(1, Ordering::SeqCst);
|
|
self.inner
|
|
.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.inner.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.inner
|
|
.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(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeStoreInner {
|
|
projects: Vec<Project>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
|
|
|
#[async_trait]
|
|
impl ProjectStore for FakeStore {
|
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
|
Ok(self.0.lock().unwrap().projects.clone())
|
|
}
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.projects
|
|
.iter()
|
|
.find(|p| p.id == id)
|
|
.cloned()
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().projects.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())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ROOT: &str = "/home/me/proj";
|
|
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
|
|
|
|
fn pid(n: u128) -> ProjectId {
|
|
ProjectId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn nid(n: u128) -> NodeId {
|
|
NodeId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn lid(n: u128) -> LayoutId {
|
|
LayoutId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn agent_leaf(node: NodeId, agent: Option<AgentId>, conv: Option<&str>, running: bool) -> LeafCell {
|
|
LeafCell {
|
|
id: node,
|
|
session: None,
|
|
agent,
|
|
conversation_id: conv.map(str::to_string),
|
|
engine_session_id: None,
|
|
agent_was_running: running,
|
|
preferred_view: domain::PreferredView::Tui,
|
|
}
|
|
}
|
|
|
|
/// Two leaves of the same agent, both flagged running, under a Row split.
|
|
fn duplicate_tree(agent: AgentId, a: NodeId, b: NodeId) -> LayoutTree {
|
|
LayoutTree::new(LayoutNode::Split(SplitContainer {
|
|
id: nid(1),
|
|
direction: Direction::Row,
|
|
children: vec![
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(a, Some(agent), Some("c-a"), true)),
|
|
weight: 1.0,
|
|
},
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(b, Some(agent), Some("c-b"), true)),
|
|
weight: 1.0,
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
async fn register_project(store: &FakeStore, id: ProjectId) {
|
|
let project = Project::new(
|
|
id,
|
|
"Demo",
|
|
ProjectPath::new(ROOT).unwrap(),
|
|
RemoteRef::Local,
|
|
0,
|
|
)
|
|
.unwrap();
|
|
store.save_project(&project).await.unwrap();
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
fn doc_json(fs: &FakeFs) -> serde_json::Value {
|
|
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap()
|
|
}
|
|
|
|
/// `agent_was_running` for the leaf `node` in the active persisted layout.
|
|
fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
|
let doc = doc_json(fs);
|
|
let tree: LayoutTree =
|
|
serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable");
|
|
fn find(node: &LayoutNode, target: NodeId) -> Option<bool> {
|
|
match node {
|
|
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
|
LayoutNode::Leaf(_) => None,
|
|
LayoutNode::CustomPluginLayout(_) => None,
|
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
|
}
|
|
}
|
|
find(&tree.root, node)
|
|
}
|
|
|
|
fn make_use_case(store: &FakeStore, fs: &FakeFs) -> ReconcileLayouts {
|
|
ReconcileLayouts::new(
|
|
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
|
|
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 6. A persisted layout with a duplicate agent ⇒ `changed == true` and the
|
|
/// persisted version keeps a single "was running" leaf for the agent.
|
|
#[tokio::test]
|
|
async fn reconcile_persists_and_reports_changed_on_duplicate() {
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
register_project(&store, pid(1)).await;
|
|
|
|
let a = nid(10);
|
|
let b = nid(11);
|
|
let agent = aid(100);
|
|
seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b));
|
|
|
|
let uc = make_use_case(&store, &fs);
|
|
let out = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.changed, true, "duplicate must be reconciled");
|
|
|
|
// Exactly one of the two leaves is still flagged running in the persisted doc.
|
|
let ra = was_running(&fs, a).expect("leaf a");
|
|
let rb = was_running(&fs, b).expect("leaf b");
|
|
assert_eq!(
|
|
[ra, rb].iter().filter(|x| **x).count(),
|
|
1,
|
|
"only one host stays running after reconciliation"
|
|
);
|
|
// First in pre-order is the host (both carried a signal).
|
|
assert_eq!(ra, true);
|
|
assert_eq!(rb, false);
|
|
}
|
|
|
|
/// 7. No-op: a second `execute` on an already-reconciled project ⇒
|
|
/// `changed == false` and **zero** writes occur.
|
|
#[tokio::test]
|
|
async fn reconcile_second_pass_is_noop_no_write() {
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
register_project(&store, pid(1)).await;
|
|
|
|
let a = nid(10);
|
|
let b = nid(11);
|
|
let agent = aid(100);
|
|
seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b));
|
|
|
|
let uc = make_use_case(&store, &fs);
|
|
|
|
// First pass reconciles + writes once.
|
|
let first = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.changed, true);
|
|
assert_eq!(fs.write_calls(), 1, "first pass persists once");
|
|
|
|
// Second pass must be a strict no-op.
|
|
let writes_before = fs.write_calls();
|
|
let second = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(second.changed, false, "already reconciled → no change");
|
|
assert_eq!(
|
|
fs.write_calls(),
|
|
writes_before,
|
|
"no-op must not write anything"
|
|
);
|
|
}
|
|
|
|
/// 8. A layout WITHOUT duplicates ⇒ `changed == false`, unchanged, no write.
|
|
#[tokio::test]
|
|
async fn reconcile_no_duplicate_is_noop() {
|
|
let store = FakeStore::default();
|
|
let fs = FakeFs::default();
|
|
register_project(&store, pid(1)).await;
|
|
|
|
let a = nid(10);
|
|
let b = nid(11);
|
|
// Distinct agents — no duplicate.
|
|
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
|
|
id: nid(1),
|
|
direction: Direction::Row,
|
|
children: vec![
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(a, Some(aid(100)), Some("c-a"), true)),
|
|
weight: 1.0,
|
|
},
|
|
WeightedChild {
|
|
node: LayoutNode::Leaf(agent_leaf(b, Some(aid(101)), None, false)),
|
|
weight: 1.0,
|
|
},
|
|
],
|
|
}));
|
|
seed_layouts(&fs, lid(1), &tree);
|
|
let before = doc_json(&fs);
|
|
let writes_before = fs.write_calls();
|
|
|
|
let uc = make_use_case(&store, &fs);
|
|
let out = uc
|
|
.execute(ReconcileLayoutsInput { project_id: pid(1) })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.changed, false, "no duplicate → no change");
|
|
assert_eq!(fs.write_calls(), writes_before, "no write on no-op");
|
|
assert_eq!(doc_json(&fs), before, "persisted doc unchanged");
|
|
assert_eq!(was_running(&fs, a), Some(true));
|
|
}
|