//! Best-effort conversation inspection use case (CONTEXT §T7, Part A). //! //! [`InspectConversation`] enriches a resume popup with the *last topic* and a //! *token indicator* read from a CLI's on-disk transcript. It is **best-effort //! by construction**: it routes the agent's [`AgentProfile`] to the first //! injected [`SessionInspector`] that [`supports`](SessionInspector::supports) //! it, and *any* miss — no inspector at all, an unsupported profile, //! [`InspectError::NotFound`], or [`InspectError::Read`] — degrades to **empty //! details** (`last_topic: None, token_count: None`) instead of an error. The //! resume must never be blocked by inspection. //! //! Extensibility (Open/Closed): adding a new inspectable CLI is *pushing one //! more adapter into the `Vec`* at the composition root — no change here. //! //! Like [`super::lifecycle::LaunchAgent`], it resolves the agent from the //! project manifest and its profile from the [`ProfileStore`], and inspects //! against the agent's **isolated run directory** (the very `cwd` the CLI was //! launched with) so the inspector points at the right transcript folder. use std::sync::Arc; use domain::ports::{ AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector, }; use domain::{AgentId, Project}; use super::lifecycle::agent_run_dir; use crate::error::AppError; /// Input for [`InspectConversation::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InspectConversationInput { /// The project owning the agent. pub project: Project, /// The agent whose conversation is being inspected. pub agent_id: AgentId, /// The persistent CLI conversation id recorded on the hosting cell. pub conversation_id: String, } /// Output of [`InspectConversation::execute`]: the (possibly empty) best-effort /// details. Never an inspection error — a miss surfaces as empty fields. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InspectConversationOutput { /// Enriched, best-effort details (every field optional). pub details: ConversationDetails, } /// Reads best-effort [`ConversationDetails`] for an agent's conversation. /// /// Holds a (possibly empty) `Vec>`: the agent's /// profile is routed to the first inspector that supports it. The use case still /// needs the context store (resolve the agent) and the profile store (resolve /// the profile), exactly like [`super::lifecycle::LaunchAgent`]. pub struct InspectConversation { contexts: Arc, profiles: Arc, inspectors: Vec>, } impl InspectConversation { /// Builds the use case from its injected ports and the inspector list (which /// may be empty: that path simply yields empty details). #[must_use] pub fn new( contexts: Arc, profiles: Arc, inspectors: Vec>, ) -> Self { Self { contexts, profiles, inspectors, } } /// Resolves the agent + profile + run dir, then asks the first supporting /// inspector for details. Returns **empty** details when no inspector /// matches or inspection misses (`NotFound`/`Read`); only genuine store /// failures (loading the manifest / profiles) surface as an error. /// /// # Errors /// - [`AppError::NotFound`] if the agent or its profile is unknown, /// - [`AppError::Invalid`] if a persisted manifest entry / run dir is invalid, /// - [`AppError::Store`] on a manifest / profile store failure. pub async fn execute( &self, input: InspectConversationInput, ) -> Result { // Resolve the agent from the manifest (for its profile + run dir). let manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; let profile = self .profiles .list() .await? .into_iter() .find(|p| p.id == agent.profile_id) .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?; // The CLI runs with its isolated run dir as cwd; the inspector keys its // transcript lookup off that same cwd (same value LaunchAgent uses). let run_dir = agent_run_dir(&input.project.root, &agent.id) .map_err(|e| AppError::Invalid(e.to_string()))?; // Route to the first inspector that supports this profile. No match ⇒ // empty details (best-effort). let Some(inspector) = self.inspectors.iter().find(|i| i.supports(&profile)) else { return Ok(InspectConversationOutput { details: empty_details(), }); }; // Any inspection miss (NotFound / Read) degrades to empty details — it // must never block a resume. let details = match inspector .details(&profile, &input.conversation_id, &run_dir) .await { Ok(details) => details, Err(InspectError::NotFound | InspectError::Read(_)) => empty_details(), }; Ok(InspectConversationOutput { details }) } } /// The empty, fully-degraded [`ConversationDetails`] (no topic, no tokens). fn empty_details() -> ConversationDetails { ConversationDetails { last_topic: None, token_count: None, } }