Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
422 lines
14 KiB
Rust
422 lines
14 KiB
Rust
//! L7 tests for the template & synchronisation use cases, with in-memory port
|
|
//! fakes (no real store/FS): `CreateTemplate`, `UpdateTemplate`,
|
|
//! `CreateAgentFromTemplate`, `DetectAgentDrift`, `SyncAgentWithTemplate`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::events::DomainEvent;
|
|
use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{
|
|
AgentContextStore, EventBus, EventStream, IdGenerator, StoreError, TemplateStore,
|
|
};
|
|
use domain::template::{AgentTemplate, TemplateVersion};
|
|
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput,
|
|
DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
|
|
UpdateTemplate, UpdateTemplateInput,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FakeTemplates(Arc<Mutex<Vec<AgentTemplate>>>);
|
|
impl FakeTemplates {
|
|
fn with(templates: Vec<AgentTemplate>) -> Self {
|
|
Self(Arc::new(Mutex::new(templates)))
|
|
}
|
|
fn get_sync(&self, id: TemplateId) -> Option<AgentTemplate> {
|
|
self.0.lock().unwrap().iter().find(|t| t.id == id).cloned()
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl TemplateStore for FakeTemplates {
|
|
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
|
|
self.get_sync(id).ok_or(StoreError::NotFound)
|
|
}
|
|
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
|
|
let mut v = self.0.lock().unwrap();
|
|
if let Some(slot) = v.iter_mut().find(|t| t.id == template.id) {
|
|
*slot = template.clone();
|
|
} else {
|
|
v.push(template.clone());
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
|
|
let mut v = self.0.lock().unwrap();
|
|
let before = v.len();
|
|
v.retain(|t| t.id != id);
|
|
if v.len() == before {
|
|
return Err(StoreError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
|
|
impl FakeContexts {
|
|
fn new(entries: Vec<ManifestEntry>) -> Self {
|
|
Self(Arc::new(Mutex::new((
|
|
AgentManifest { version: 1, entries },
|
|
HashMap::new(),
|
|
))))
|
|
}
|
|
fn manifest(&self) -> AgentManifest {
|
|
self.0.lock().unwrap().0.clone()
|
|
}
|
|
fn content(&self, md_path: &str) -> Option<String> {
|
|
self.0.lock().unwrap().1.get(md_path).cloned()
|
|
}
|
|
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.0
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.md_path.clone())
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_p: &Project,
|
|
agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound)
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_p: &Project,
|
|
agent: &AgentId,
|
|
md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0.lock().unwrap().1.insert(path, md.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.manifest())
|
|
}
|
|
async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().0 = m.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl SeqIds {
|
|
fn new() -> Self {
|
|
Self(Mutex::new(1))
|
|
}
|
|
}
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn tid(n: u128) -> TemplateId {
|
|
TemplateId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn v(n: u64) -> TemplateVersion {
|
|
TemplateVersion(n)
|
|
}
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new("/home/me/demo").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTemplate {
|
|
let mut t = AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap();
|
|
// Bump to the requested version by re-applying content updates.
|
|
while t.version.get() < version {
|
|
t = t.with_updated_content(MarkdownDoc::new(content));
|
|
}
|
|
t
|
|
}
|
|
/// A synchronized, template-backed manifest entry synced at `synced`.
|
|
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry {
|
|
ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateTemplate / UpdateTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn create_template_starts_at_initial_version() {
|
|
let store = FakeTemplates::default();
|
|
let out = CreateTemplate::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
|
|
.execute(CreateTemplateInput {
|
|
name: "Backend".to_owned(),
|
|
content: "# ctx".to_owned(),
|
|
default_profile_id: pid(7),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.template.version, TemplateVersion::INITIAL);
|
|
assert_eq!(out.template.default_profile_id, pid(7));
|
|
assert_eq!(store.list().await.unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_template_bumps_version_and_publishes_event() {
|
|
let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]);
|
|
let bus = SpyBus::default();
|
|
let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
|
.execute(UpdateTemplateInput {
|
|
template_id: tid(1),
|
|
content: "v2".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.template.version.get(), 2);
|
|
assert_eq!(store.get_sync(tid(1)).unwrap().content_md.as_str(), "v2");
|
|
assert_eq!(
|
|
bus.events(),
|
|
vec![DomainEvent::TemplateUpdated {
|
|
template_id: tid(1),
|
|
version: v(2),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_unknown_template_is_not_found() {
|
|
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default()))
|
|
.execute(UpdateTemplateInput {
|
|
template_id: tid(404),
|
|
content: "x".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateAgentFromTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn create_agent_from_template_links_origin_and_seeds_context() {
|
|
let store = FakeTemplates::with(vec![template(tid(1), "Backend", "# body", 4)]);
|
|
let contexts = FakeContexts::new(vec![]);
|
|
let out = CreateAgentFromTemplate::new(
|
|
Arc::new(store),
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(SeqIds::new()),
|
|
Arc::new(SpyBus::default()),
|
|
)
|
|
.execute(CreateAgentFromTemplateInput {
|
|
project: project(),
|
|
template_id: tid(1),
|
|
name: None,
|
|
synchronized: true,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Name defaults to the template name; profile = template default.
|
|
assert_eq!(out.agent.name, "Backend");
|
|
assert_eq!(out.agent.profile_id, pid(1));
|
|
assert!(out.agent.synchronized);
|
|
assert_eq!(
|
|
out.agent.origin,
|
|
domain::AgentOrigin::FromTemplate {
|
|
template_id: tid(1),
|
|
synced_template_version: v(4),
|
|
}
|
|
);
|
|
// Context seeded with the template content under the agent's md path.
|
|
assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body"));
|
|
assert_eq!(contexts.manifest().entries.len(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DetectAgentDrift
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn detect_drift_flags_only_synchronized_agents_behind() {
|
|
// Template at v3.
|
|
let store = FakeTemplates::with(vec![template(tid(1), "T", "v3", 3)]);
|
|
// a1: synchronized, synced at v1 → drift (1→3).
|
|
// a2: synchronized, synced at v3 → up to date, no drift.
|
|
// a3: from template but NOT synchronized → ignored.
|
|
// a4: scratch (no template) → ignored.
|
|
let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
|
.unwrap();
|
|
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
|
|
let contexts = FakeContexts::new(vec![
|
|
synced_entry(aid(1), "agents/a1.md", tid(1), 1),
|
|
synced_entry(aid(2), "agents/a2.md", tid(1), 3),
|
|
a3,
|
|
a4,
|
|
]);
|
|
let bus = SpyBus::default();
|
|
|
|
let out = DetectAgentDrift::new(
|
|
Arc::new(store),
|
|
Arc::new(contexts),
|
|
Arc::new(bus.clone()),
|
|
)
|
|
.execute(DetectAgentDriftInput { project: project() })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.drifts.len(), 1, "only a1 drifts");
|
|
assert_eq!(out.drifts[0].agent_id, aid(1));
|
|
assert_eq!(out.drifts[0].from, v(1));
|
|
assert_eq!(out.drifts[0].to, v(3));
|
|
assert_eq!(
|
|
bus.events(),
|
|
vec![DomainEvent::AgentDriftDetected {
|
|
agent_id: aid(1),
|
|
from: v(1),
|
|
to: v(3),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_drift_ignores_deleted_template() {
|
|
// No templates in the store, but an agent references tid(1): not an error.
|
|
let store = FakeTemplates::default();
|
|
let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]);
|
|
let out = DetectAgentDrift::new(
|
|
Arc::new(store),
|
|
Arc::new(contexts),
|
|
Arc::new(SpyBus::default()),
|
|
)
|
|
.execute(DetectAgentDriftInput { project: project() })
|
|
.await
|
|
.unwrap();
|
|
assert!(out.drifts.is_empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SyncAgentWithTemplate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn sync_applies_to_synchronized_and_updates_version_and_context() {
|
|
let store = FakeTemplates::with(vec![template(tid(1), "T", "newest body", 3)]);
|
|
let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]);
|
|
// Seed an old context so we can see the replacement.
|
|
contexts
|
|
.write_context(&project(), &aid(1), &MarkdownDoc::new("old"))
|
|
.await
|
|
.unwrap();
|
|
let bus = SpyBus::default();
|
|
|
|
let out = SyncAgentWithTemplate::new(
|
|
Arc::new(store),
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(bus.clone()),
|
|
)
|
|
.execute(SyncAgentWithTemplateInput {
|
|
project: project(),
|
|
agent_id: aid(1),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(out.synced);
|
|
assert_eq!(out.version, Some(v(3)));
|
|
// Context replaced by the template content.
|
|
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body"));
|
|
// Manifest synced version advanced to 3.
|
|
let entry = &contexts.manifest().entries[0];
|
|
assert_eq!(entry.synced_template_version, Some(v(3)));
|
|
assert_eq!(
|
|
bus.events(),
|
|
vec![DomainEvent::AgentSynced {
|
|
agent_id: aid(1),
|
|
to: v(3),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_ignores_non_synchronized_agent() {
|
|
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
|
|
// Non-synchronized agent from a template.
|
|
let entry =
|
|
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
|
.unwrap();
|
|
let contexts = FakeContexts::new(vec![entry]);
|
|
contexts
|
|
.write_context(&project(), &aid(1), &MarkdownDoc::new("keep me"))
|
|
.await
|
|
.unwrap();
|
|
let bus = SpyBus::default();
|
|
|
|
let out = SyncAgentWithTemplate::new(
|
|
Arc::new(store),
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(bus.clone()),
|
|
)
|
|
.execute(SyncAgentWithTemplateInput {
|
|
project: project(),
|
|
agent_id: aid(1),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!out.synced, "non-synchronized agent is left untouched");
|
|
assert_eq!(out.version, None);
|
|
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me"));
|
|
assert!(bus.events().is_empty(), "no sync event for an ignored agent");
|
|
}
|