//! 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>); impl FakeContexts { fn with_agent(agent: &Agent) -> Self { Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries: vec![ManifestEntry::from_agent(agent)], }))) } fn empty() -> Self { Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries: Vec::new(), }))) } } #[async_trait] impl AgentContextStore for FakeContexts { async fn read_context( &self, _project: &Project, _agent: &AgentId, ) -> Result { 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 { 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>); #[async_trait] impl ProfileStore for FakeProfiles { async fn list(&self) -> Result, 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 { 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>>; struct FakeInspector { supports: bool, result: InspectResult, seen: SeenProbe, } impl FakeInspector { fn new(supports: bool, result: InspectResult) -> (Arc, 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 { *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"); }