Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).
Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
7.3 KiB
Rust
214 lines
7.3 KiB
Rust
//! L6 integration tests for [`IdeaiContextStore`] against a real temp directory
|
|
//! and a real [`LocalFileSystem`], exercising the full `.ideai/` persistence path
|
|
//! (manifest JSON, context `.md` round-trip, tolerant reads, NotFound).
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|
use domain::ids::{AgentId, ProfileId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{AgentContextStore, FileSystem, RemotePath, StoreError};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use infrastructure::{IdeaiContextStore, LocalFileSystem};
|
|
use uuid::Uuid;
|
|
|
|
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
|
struct TempDir(PathBuf);
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let p = std::env::temp_dir().join(format!("idea-l6-ctx-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
Self(p)
|
|
}
|
|
fn root(&self) -> String {
|
|
self.0.to_string_lossy().into_owned()
|
|
}
|
|
fn child(&self, rel: &str) -> RemotePath {
|
|
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn store() -> IdeaiContextStore {
|
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
|
IdeaiContextStore::new(fs)
|
|
}
|
|
|
|
fn project(root: &str) -> Project {
|
|
Project::new(
|
|
domain::ids::ProjectId::new_random(),
|
|
"demo",
|
|
ProjectPath::new(root).unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn agent(id: AgentId, name: &str, md: &str, profile: ProfileId) -> Agent {
|
|
Agent::new(id, name, md, profile, AgentOrigin::Scratch, false).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_manifest_loads_empty() {
|
|
let tmp = TempDir::new();
|
|
let store = store();
|
|
let manifest = store.load_manifest(&project(&tmp.root())).await.unwrap();
|
|
assert!(manifest.entries.is_empty());
|
|
assert_eq!(manifest.version, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manifest_save_then_load_roundtrips() {
|
|
let tmp = TempDir::new();
|
|
let store = store();
|
|
let p = project(&tmp.root());
|
|
|
|
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
|
|
store.save_manifest(&p, &manifest).await.unwrap();
|
|
|
|
let back = store.load_manifest(&p).await.unwrap();
|
|
assert_eq!(back, manifest);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn context_write_then_read_roundtrips() {
|
|
let tmp = TempDir::new();
|
|
let store = store();
|
|
let p = project(&tmp.root());
|
|
|
|
// The manifest must know the agent before its context can be addressed.
|
|
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
|
|
store.save_manifest(&p, &manifest).await.unwrap();
|
|
|
|
let md = MarkdownDoc::new("# Backend\nYou are the backend agent.");
|
|
store.write_context(&p, &a.id, &md).await.unwrap();
|
|
|
|
let back = store.read_context(&p, &a.id).await.unwrap();
|
|
assert_eq!(back, md);
|
|
|
|
// The `.md` actually landed at `.ideai/agents/backend.md`.
|
|
let fs = LocalFileSystem::new();
|
|
let bytes = fs
|
|
.read(&tmp.child(".ideai/agents/backend.md"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(String::from_utf8(bytes).unwrap(), md.as_str());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_context_for_unknown_agent_is_not_found() {
|
|
let tmp = TempDir::new();
|
|
let store = store();
|
|
let p = project(&tmp.root());
|
|
let err = store.read_context(&p, &aid(404)).await.unwrap_err();
|
|
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manifest_file_is_camelcase_json_under_ideai() {
|
|
let tmp = TempDir::new();
|
|
let store = store();
|
|
let p = project(&tmp.root());
|
|
|
|
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
|
|
store.save_manifest(&p, &manifest).await.unwrap();
|
|
|
|
let fs = LocalFileSystem::new();
|
|
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
|
|
let agents = json
|
|
.get("agents")
|
|
.and_then(|v| v.as_array())
|
|
.expect("top-level `agents` array");
|
|
assert_eq!(agents.len(), 1);
|
|
let entry = &agents[0];
|
|
assert_eq!(
|
|
entry.get("mdPath").and_then(|v| v.as_str()),
|
|
Some("agents/backend.md")
|
|
);
|
|
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
|
|
assert!(
|
|
entry.get("profileId").is_some(),
|
|
"camelCase profileId present"
|
|
);
|
|
assert!(entry.get("md_path").is_none(), "no snake_case leak");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_manifest_without_orchestrator_loads_as_default() {
|
|
// A pre-feature `agents.json` has no `orchestrator` key. It must deserialise with
|
|
// `orchestrator == None` (serde default), so `effective_orchestrator()` falls back
|
|
// to the oldest entry (free backward compatibility).
|
|
let tmp = TempDir::new();
|
|
let p = project(&tmp.root());
|
|
let fs = LocalFileSystem::new();
|
|
fs.create_dir_all(&tmp.child(".ideai")).await.unwrap();
|
|
let legacy = r#"{
|
|
"version": 1,
|
|
"agents": [
|
|
{ "agentId": "00000000-0000-0000-0000-000000000001", "name": "Oldest",
|
|
"mdPath": "agents/oldest.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
|
|
"synchronized": false },
|
|
{ "agentId": "00000000-0000-0000-0000-000000000002", "name": "Newer",
|
|
"mdPath": "agents/newer.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
|
|
"synchronized": false }
|
|
]
|
|
}"#;
|
|
fs.write(&tmp.child(".ideai/agents.json"), legacy.as_bytes())
|
|
.await
|
|
.unwrap();
|
|
|
|
let manifest = store().load_manifest(&p).await.unwrap();
|
|
assert_eq!(manifest.orchestrator, None);
|
|
assert_eq!(manifest.effective_orchestrator(), Some(aid(1)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manifest_with_orchestrator_roundtrips_and_emits_camelcase_key() {
|
|
let tmp = TempDir::new();
|
|
let store = store();
|
|
let p = project(&tmp.root());
|
|
|
|
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let b = agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
|
let mut manifest = AgentManifest::new(
|
|
1,
|
|
vec![ManifestEntry::from_agent(&a), ManifestEntry::from_agent(&b)],
|
|
)
|
|
.unwrap();
|
|
manifest.designate(aid(2)).unwrap();
|
|
store.save_manifest(&p, &manifest).await.unwrap();
|
|
|
|
// Round-trips byte-for-byte through the store…
|
|
let back = store.load_manifest(&p).await.unwrap();
|
|
assert_eq!(back, manifest);
|
|
assert_eq!(back.effective_orchestrator(), Some(aid(2)));
|
|
|
|
// …and the on-disk JSON carries the camelCase `orchestrator` key.
|
|
let fs = LocalFileSystem::new();
|
|
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(
|
|
json.get("orchestrator").and_then(|v| v.as_str()),
|
|
Some("00000000-0000-0000-0000-000000000002")
|
|
);
|
|
}
|