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>
384 lines
12 KiB
Rust
384 lines
12 KiB
Rust
//! T7 tests for the [`InspectConversation`] use case (best-effort conversation
|
|
//! inspection feeding the resume popup).
|
|
//!
|
|
//! Each port is faked in-memory:
|
|
//! - [`FakeContexts`] — an [`AgentContextStore`] holding a manifest seeded with
|
|
//! one agent,
|
|
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
|
//! - [`FakeInspector`] — a [`SessionInspector`] whose `supports`/`details` are
|
|
//! configurable, so we can exercise: a supporting inspector returning details,
|
|
//! one returning `NotFound`, and the empty-`Vec` (no inspector) path.
|
|
//!
|
|
//! The contract under test (CONTEXT §T7 Part A): inspection is **best-effort** —
|
|
//! any miss (no inspector, unsupported profile, `NotFound`/`Read`) degrades to
|
|
//! empty details, never an error; only a genuine store failure errors.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{
|
|
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
|
|
StoreError,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use uuid::Uuid;
|
|
|
|
use application::{InspectConversation, InspectConversationInput};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeContexts (AgentContextStore) — only load_manifest is exercised here
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<AgentManifest>>);
|
|
|
|
impl FakeContexts {
|
|
fn with_agent(agent: &Agent) -> Self {
|
|
Self(Arc::new(Mutex::new(AgentManifest {
|
|
version: 1,
|
|
entries: vec![ManifestEntry::from_agent(agent)],
|
|
orchestrator: None,
|
|
})))
|
|
}
|
|
fn empty() -> Self {
|
|
Self(Arc::new(Mutex::new(AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
orchestrator: None,
|
|
})))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
_agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
Ok(MarkdownDoc::new(""))
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
_agent: &AgentId,
|
|
_md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
*self.0.lock().unwrap() = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeProfiles (ProfileStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok((*self.0).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(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeInspector (SessionInspector) — configurable support + result
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What the fake inspector returns from `details`.
|
|
#[derive(Clone)]
|
|
enum InspectResult {
|
|
Ok(ConversationDetails),
|
|
Err(InspectError),
|
|
}
|
|
|
|
/// Shared probe recording the `(conversation_id, cwd)` an inspection was asked
|
|
/// for (so a test can assert the run dir was routed, not the project root).
|
|
type SeenProbe = Arc<Mutex<Option<(String, String)>>>;
|
|
|
|
struct FakeInspector {
|
|
supports: bool,
|
|
result: InspectResult,
|
|
seen: SeenProbe,
|
|
}
|
|
|
|
impl FakeInspector {
|
|
fn new(supports: bool, result: InspectResult) -> (Arc<Self>, SeenProbe) {
|
|
let seen = Arc::new(Mutex::new(None));
|
|
let me = Arc::new(Self {
|
|
supports,
|
|
result,
|
|
seen: Arc::clone(&seen),
|
|
});
|
|
(me, seen)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SessionInspector for FakeInspector {
|
|
fn supports(&self, _profile: &AgentProfile) -> bool {
|
|
self.supports
|
|
}
|
|
async fn details(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
conversation_id: &str,
|
|
cwd: &ProjectPath,
|
|
) -> Result<ConversationDetails, InspectError> {
|
|
*self.seen.lock().unwrap() = Some((conversation_id.to_owned(), cwd.as_str().to_owned()));
|
|
match &self.result {
|
|
InspectResult::Ok(d) => Ok(d.clone()),
|
|
InspectResult::Err(e) => Err(e.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn profile(id: ProfileId) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude Code",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::ConventionFile {
|
|
target: "CLAUDE.md".to_owned(),
|
|
},
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn agent(id: AgentId, profile_id: ProfileId) -> Agent {
|
|
Agent::new(
|
|
id,
|
|
"Bob",
|
|
"agents/bob.md",
|
|
profile_id,
|
|
AgentOrigin::Scratch,
|
|
false,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn input(agent_id: AgentId) -> InspectConversationInput {
|
|
InspectConversationInput {
|
|
project: project(),
|
|
agent_id,
|
|
conversation_id: "conv-123".to_owned(),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (a) supporting inspector → details propagated
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn supporting_inspector_propagates_details() {
|
|
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
|
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
|
let a = agent(aid, pid);
|
|
|
|
let (inspector, seen) = FakeInspector::new(
|
|
true,
|
|
InspectResult::Ok(ConversationDetails {
|
|
last_topic: Some("refactor the parser".to_owned()),
|
|
token_count: Some(4242),
|
|
}),
|
|
);
|
|
|
|
let uc = InspectConversation::new(
|
|
Arc::new(FakeContexts::with_agent(&a)),
|
|
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
|
vec![inspector],
|
|
);
|
|
|
|
let out = uc.execute(input(aid)).await.unwrap();
|
|
assert_eq!(
|
|
out.details.last_topic.as_deref(),
|
|
Some("refactor the parser")
|
|
);
|
|
assert_eq!(out.details.token_count, Some(4242));
|
|
|
|
// Routed the conversation id and the agent's isolated run dir (not the root).
|
|
let (conv, cwd) = seen.lock().unwrap().clone().expect("inspector was called");
|
|
assert_eq!(conv, "conv-123");
|
|
assert_eq!(cwd, format!("/home/me/proj/.ideai/run/{aid}"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (b) inspector returns NotFound → empty details, no error
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn not_found_degrades_to_empty_details() {
|
|
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
|
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
|
let a = agent(aid, pid);
|
|
|
|
let (inspector, _seen) = FakeInspector::new(true, InspectResult::Err(InspectError::NotFound));
|
|
|
|
let uc = InspectConversation::new(
|
|
Arc::new(FakeContexts::with_agent(&a)),
|
|
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
|
vec![inspector],
|
|
);
|
|
|
|
let out = uc.execute(input(aid)).await.unwrap();
|
|
assert_eq!(out.details.last_topic, None);
|
|
assert_eq!(out.details.token_count, None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (b') inspector returns Read error → empty details, no error
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn read_error_degrades_to_empty_details() {
|
|
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
|
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
|
let a = agent(aid, pid);
|
|
|
|
let (inspector, _seen) = FakeInspector::new(
|
|
true,
|
|
InspectResult::Err(InspectError::Read("boom".to_owned())),
|
|
);
|
|
|
|
let uc = InspectConversation::new(
|
|
Arc::new(FakeContexts::with_agent(&a)),
|
|
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
|
vec![inspector],
|
|
);
|
|
|
|
let out = uc.execute(input(aid)).await.unwrap();
|
|
assert_eq!(
|
|
out.details,
|
|
ConversationDetails {
|
|
last_topic: None,
|
|
token_count: None
|
|
}
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (c) empty Vec (no inspector) → empty details, no error
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn no_inspector_yields_empty_details() {
|
|
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
|
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
|
let a = agent(aid, pid);
|
|
|
|
let uc = InspectConversation::new(
|
|
Arc::new(FakeContexts::with_agent(&a)),
|
|
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
|
Vec::new(),
|
|
);
|
|
|
|
let out = uc.execute(input(aid)).await.unwrap();
|
|
assert_eq!(out.details.last_topic, None);
|
|
assert_eq!(out.details.token_count, None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (c') an inspector that does NOT support the profile is skipped → empty
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn unsupported_inspector_is_skipped() {
|
|
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
|
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
|
let a = agent(aid, pid);
|
|
|
|
let (inspector, seen) = FakeInspector::new(
|
|
false, // does not support
|
|
InspectResult::Ok(ConversationDetails {
|
|
last_topic: Some("should never surface".to_owned()),
|
|
token_count: Some(1),
|
|
}),
|
|
);
|
|
|
|
let uc = InspectConversation::new(
|
|
Arc::new(FakeContexts::with_agent(&a)),
|
|
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
|
vec![inspector],
|
|
);
|
|
|
|
let out = uc.execute(input(aid)).await.unwrap();
|
|
assert_eq!(out.details.last_topic, None);
|
|
assert_eq!(out.details.token_count, None);
|
|
// details() must not have been called.
|
|
assert!(seen.lock().unwrap().is_none());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Genuine resolution failures still error (unknown agent)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn unknown_agent_errors() {
|
|
let aid = AgentId::from_uuid(Uuid::from_u128(99));
|
|
let (inspector, _seen) = FakeInspector::new(
|
|
true,
|
|
InspectResult::Ok(ConversationDetails {
|
|
last_topic: None,
|
|
token_count: None,
|
|
}),
|
|
);
|
|
|
|
let uc = InspectConversation::new(
|
|
Arc::new(FakeContexts::empty()),
|
|
Arc::new(FakeProfiles(Arc::new(Vec::new()))),
|
|
vec![inspector],
|
|
);
|
|
|
|
let err = uc.execute(input(aid)).await.unwrap_err();
|
|
// Should be a NOT_FOUND-class error, not a panic / empty success.
|
|
assert_eq!(err.code(), "NOT_FOUND");
|
|
}
|