//! Use cases for ephemeral AI ticket editing assistants. use std::sync::Arc; use domain::ports::{AgentSessionFactory, SessionPlan}; use domain::AgentProfile; use domain::{ AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus, IssueRef, IssueStore, ProfileId, ProfileStore, Project, SessionId, }; use crate::terminal::StructuredSessions; use crate::AppError; /// Opens an ephemeral ticket assistant chat session. pub struct OpenTicketAssistant { issues: Arc, profiles: Arc, contexts: Arc, factory: Arc, structured: Arc, policies: Arc, events: Arc, } /// Input for [`OpenTicketAssistant`]. pub struct OpenTicketAssistantInput { /// Project owning the ticket. pub project: Project, /// Ticket reference to bind. pub issue_ref: IssueRef, /// Structured profile to use. pub profile_id: ProfileId, } /// Output of [`OpenTicketAssistant`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenTicketAssistantOutput { /// Live structured session id, to be driven by `agent_send`. pub session_id: SessionId, /// MCP requester identity bound to this assistant. pub requester: String, /// Bound ticket reference. pub issue_ref: IssueRef, } impl OpenTicketAssistant { /// Builds the use case. #[allow(clippy::too_many_arguments)] #[must_use] pub fn new( issues: Arc, profiles: Arc, contexts: Arc, factory: Arc, structured: Arc, policies: Arc, events: Arc, ) -> Self { Self { issues, profiles, contexts, factory, structured, policies, events, } } /// Executes the open flow. /// /// Reopening an already-live assistant for the same ticket replaces the old /// session after the fresh one has started. This guarantees the assistant sees /// the latest ticket content/profile and keeps the invariant "one assistant per /// ticket" without leaving a gap if the new launch fails. pub async fn execute( &self, input: OpenTicketAssistantInput, ) -> Result { let issue = self .issues .get_by_ref(&input.project.root, input.issue_ref) .await?; let profile = find_profile(&self.profiles, input.profile_id).await?; if !self.factory.supports(&profile) { return Err(AppError::Invalid(format!( "profile {} cannot start a structured ticket assistant", input.profile_id ))); } let prepared = self .contexts .prepare_ticket_assistant_context(&input.project, &issue) .await .map_err(|e| AppError::Store(e.to_string()))?; let session = self .factory .start( &profile, &prepared, &input.project.root, &SessionPlan::None, &[], None, ) .await .map_err(AppError::from)?; let session_id = session.id(); let requester = session_id.to_string(); let policy = AgentToolPolicy::new( vec![ "idea_ticket_read".to_owned(), "idea_ticket_update".to_owned(), "idea_ticket_update_carnet".to_owned(), ], Some(input.issue_ref), true, ); if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) { self.policies.clear_policy(&old_requester); } let replaced = self.structured.insert_ticket_assistant( input.issue_ref, requester.clone(), Arc::clone(&session), ); self.policies.set_policy(requester.clone(), policy); if let Some(old) = replaced { let _ = old.shutdown().await; } self.events.publish(DomainEvent::TicketAssistantOpened { issue_ref: input.issue_ref, profile_id: input.profile_id, }); Ok(OpenTicketAssistantOutput { session_id, requester, issue_ref: input.issue_ref, }) } } /// Closes an ephemeral ticket assistant chat session. pub struct CloseTicketAssistant { structured: Arc, policies: Arc, events: Arc, } /// Input for [`CloseTicketAssistant`]. pub struct CloseTicketAssistantInput { /// Ticket reference whose assistant should be closed. pub issue_ref: IssueRef, } impl CloseTicketAssistant { /// Builds the use case. #[must_use] pub fn new( structured: Arc, policies: Arc, events: Arc, ) -> Self { Self { structured, policies, events, } } /// Executes the close flow. pub async fn execute(&self, input: CloseTicketAssistantInput) -> Result<(), AppError> { let (session, requester) = self .structured .remove_ticket_assistant(input.issue_ref) .ok_or_else(|| AppError::NotFound(format!("ticket assistant {}", input.issue_ref)))?; self.policies.clear_policy(&requester); session.shutdown().await.map_err(AppError::from)?; self.events.publish(DomainEvent::TicketAssistantClosed { issue_ref: input.issue_ref, }); Ok(()) } } async fn find_profile( profiles: &Arc, profile_id: ProfileId, ) -> Result { profiles .list() .await? .into_iter() .find(|profile| profile.id == profile_id) .ok_or_else(|| AppError::NotFound(format!("profile {profile_id}"))) }