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:
246
crates/domain/tests/agent_profile_a0.rs
Normal file
246
crates/domain/tests/agent_profile_a0.rs
Normal file
@ -0,0 +1,246 @@
|
||||
//! LOT A0 (fondation) — tests purs du domaine :
|
||||
//! - `Agent::with_profile` ne change **que** `profile_id` ;
|
||||
//! - `LayoutTree::leaf` retrouve/loupe un node (feuille vs split/grid) ;
|
||||
//! - variante d'event `DomainEvent::AgentProfileChanged`.
|
||||
//!
|
||||
//! Réutilise les fixtures/helpers existants (`helpers::node`, `helpers::session`)
|
||||
//! et colle au style des tests `entities.rs` / `layout.rs` (ARCHITECTURE §15.4 A0).
|
||||
|
||||
mod helpers;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, SkillId, TemplateId};
|
||||
use domain::{
|
||||
Agent, AgentOrigin, Direction, LayoutNode, LayoutTree, LeafCell, SkillRef, SkillScope,
|
||||
SplitContainer, TemplateVersion, WeightedChild,
|
||||
};
|
||||
use helpers::{node, session};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn agent_id(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn profile_id(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn template_id(n: u128) -> TemplateId {
|
||||
TemplateId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn skill_ref(n: u128) -> SkillRef {
|
||||
SkillRef::new(SkillId::from_uuid(Uuid::from_u128(n)), SkillScope::Project)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent::with_profile — ne change QUE profile_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Un agent issu d'un template, synchronisé, avec des skills : cas le plus riche
|
||||
/// pour prouver que `with_profile` ne touche à rien d'autre que `profile_id`.
|
||||
fn rich_agent() -> Agent {
|
||||
Agent::new(
|
||||
agent_id(1),
|
||||
"Architect",
|
||||
"agents/architect.md",
|
||||
profile_id(10),
|
||||
AgentOrigin::FromTemplate {
|
||||
template_id: template_id(7),
|
||||
synced_template_version: TemplateVersion::INITIAL,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.expect("valid rich agent")
|
||||
.with_skills(vec![skill_ref(100), skill_ref(101)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_profile_changes_only_profile_id() {
|
||||
let before = rich_agent();
|
||||
let new_profile = profile_id(99);
|
||||
let after = before.clone().with_profile(new_profile);
|
||||
|
||||
// Le seul champ muté :
|
||||
assert_eq!(after.profile_id, new_profile);
|
||||
assert_ne!(after.profile_id, before.profile_id);
|
||||
|
||||
// Tous les autres champs sont strictement inchangés :
|
||||
assert_eq!(after.id, before.id);
|
||||
assert_eq!(after.name, before.name);
|
||||
assert_eq!(after.context_path, before.context_path);
|
||||
assert_eq!(after.origin, before.origin);
|
||||
assert_eq!(after.synchronized, before.synchronized);
|
||||
assert_eq!(after.skills, before.skills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_profile_preserves_scratch_origin_and_unsynced() {
|
||||
let before = Agent::new(
|
||||
agent_id(2),
|
||||
"Scratchy",
|
||||
"agents/scratchy.md",
|
||||
profile_id(10),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.expect("valid scratch agent");
|
||||
|
||||
let after = before.clone().with_profile(profile_id(11));
|
||||
|
||||
assert_eq!(after.profile_id, profile_id(11));
|
||||
assert_eq!(after.origin, AgentOrigin::Scratch);
|
||||
assert!(!after.synchronized);
|
||||
assert!(after.skills.is_empty());
|
||||
// Tout sauf le profil identique → comparer un clone "patché" prouve l'égalité.
|
||||
assert_eq!(after, before.with_profile(profile_id(11)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_profile_to_same_profile_is_identity() {
|
||||
// Idempotence / égalité attendue : re-poser le même profil ⇒ agent inchangé.
|
||||
let before = rich_agent();
|
||||
let after = before.clone().with_profile(before.profile_id);
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_profile_is_idempotent_when_repeated() {
|
||||
let base = rich_agent();
|
||||
let once = base.clone().with_profile(profile_id(42));
|
||||
let twice = once.clone().with_profile(profile_id(42));
|
||||
assert_eq!(once, twice);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LayoutTree::leaf — retrouve / loupe (feuille vs split/grid)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn leaf_cell(id: u128, sess: Option<u128>) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node(id),
|
||||
session: sess.map(session),
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Arbre contenant **au moins un split** : racine = split (id 9) de deux feuilles
|
||||
/// (id 1 avec session, id 2 sans).
|
||||
fn split_tree() -> LayoutTree {
|
||||
LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: node(9),
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(leaf_cell(1, Some(100))),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(leaf_cell(2, None)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_finds_existing_leaf_in_split_tree() {
|
||||
let tree = split_tree();
|
||||
|
||||
// Feuille 1 : retrouvée, et c'est exactement le LeafCell attendu.
|
||||
let expected = leaf_cell(1, Some(100));
|
||||
let found = tree.leaf(node(1)).expect("leaf 1 exists");
|
||||
assert_eq!(*found, expected);
|
||||
|
||||
// Feuille 2 (sans session) aussi.
|
||||
let found2 = tree.leaf(node(2)).expect("leaf 2 exists");
|
||||
assert_eq!(*found2, leaf_cell(2, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_returns_none_for_absent_node() {
|
||||
let tree = split_tree();
|
||||
assert!(tree.leaf(node(404)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_returns_none_for_split_node_id() {
|
||||
// L'id 9 existe mais désigne un split, pas une feuille ⇒ None.
|
||||
let tree = split_tree();
|
||||
assert!(tree.leaf(node(9)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_returns_none_for_grid_node_id() {
|
||||
use domain::{GridCell, GridContainer};
|
||||
|
||||
// Grille 1x1 (id 50) contenant une feuille (id 3).
|
||||
let grid = LayoutTree::new(LayoutNode::Grid(GridContainer {
|
||||
id: node(50),
|
||||
col_weights: vec![1.0],
|
||||
row_weights: vec![1.0],
|
||||
cells: vec![GridCell {
|
||||
node: LayoutNode::Leaf(leaf_cell(3, None)),
|
||||
row: 0,
|
||||
col: 0,
|
||||
row_span: 1,
|
||||
col_span: 1,
|
||||
}],
|
||||
}));
|
||||
|
||||
// L'id de la grille n'est pas une feuille.
|
||||
assert!(grid.leaf(node(50)).is_none());
|
||||
// La feuille imbriquée est bien retrouvée.
|
||||
assert_eq!(*grid.leaf(node(3)).expect("nested leaf"), leaf_cell(3, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_finds_single_root_leaf() {
|
||||
let tree = LayoutTree::single(leaf_cell(7, Some(70)));
|
||||
assert_eq!(*tree.leaf(node(7)).expect("root leaf"), leaf_cell(7, Some(70)));
|
||||
assert!(tree.leaf(node(8)).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DomainEvent::AgentProfileChanged — construction & champs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_profile_changed_carries_agent_and_profile() {
|
||||
let aid = agent_id(1);
|
||||
let pid = profile_id(2);
|
||||
let event = DomainEvent::AgentProfileChanged {
|
||||
agent_id: aid,
|
||||
profile_id: pid,
|
||||
};
|
||||
|
||||
match event {
|
||||
DomainEvent::AgentProfileChanged {
|
||||
agent_id,
|
||||
profile_id,
|
||||
} => {
|
||||
assert_eq!(agent_id, aid);
|
||||
assert_eq!(profile_id, pid);
|
||||
}
|
||||
other => panic!("expected AgentProfileChanged, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profile_changed_equality_and_clone() {
|
||||
let e1 = DomainEvent::AgentProfileChanged {
|
||||
agent_id: agent_id(1),
|
||||
profile_id: profile_id(2),
|
||||
};
|
||||
// Clone == original ; un profil différent rompt l'égalité.
|
||||
assert_eq!(e1.clone(), e1);
|
||||
assert_ne!(
|
||||
e1,
|
||||
DomainEvent::AgentProfileChanged {
|
||||
agent_id: agent_id(1),
|
||||
profile_id: profile_id(3),
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user