Files
IdeA/crates/application/src/ticket_assistant.rs
Blomios 8570adb8e0 fix(ticket-assistant): édition de ticket via les tools MCP idea_ticket_* (#27)
L'assistant IA d'édition de ticket éditait les fichiers du ticket en direct,
hors de tout contrôle. Il passe désormais par les tools MCP idea_ticket_* :
préparation d'un environnement structuré dédié et policy d'enforcement scopée
au ticket courant, de sorte que l'assistant ne peut agir que sur son ticket
via la surface MCP plutôt que sur le système de fichiers.

Couvert par de nouveaux tests QA (mcp_server, assistant_context_store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:10:23 +02:00

246 lines
7.7 KiB
Rust

//! Use cases for ephemeral AI ticket editing assistants.
use std::sync::Arc;
use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer};
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<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
}
/// 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<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
issues,
profiles,
contexts,
environment,
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<OpenTicketAssistantOutput, AppError> {
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 requester = ticket_assistant_requester(&input.project, input.issue_ref);
let had_existing = self
.structured
.ticket_assistant_requester(input.issue_ref)
.is_some();
let policy = AgentToolPolicy::new(
vec![
"idea_ticket_read".to_owned(),
"idea_ticket_update".to_owned(),
"idea_ticket_update_status".to_owned(),
"idea_ticket_update_priority".to_owned(),
"idea_ticket_read_carnet".to_owned(),
"idea_ticket_update_carnet".to_owned(),
"idea_ticket_link".to_owned(),
"idea_ticket_unlink".to_owned(),
],
Some(input.issue_ref),
true,
);
self.policies.set_policy(requester.clone(), policy);
let environment = match self
.environment
.prepare_ticket_assistant(
&input.project,
input.issue_ref,
&profile,
&prepared,
&requester,
)
.await
{
Ok(environment) => environment,
Err(err) => {
if !had_existing {
self.policies.clear_policy(&requester);
}
return Err(AppError::from(err));
}
};
let session = self
.factory
.start(
&profile,
&prepared,
&environment.cwd,
&SessionPlan::None,
&environment.env,
None,
)
.await
.map_err(|err| {
if !had_existing {
self.policies.clear_policy(&requester);
}
AppError::from(err)
})?;
let session_id = session.id();
if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) {
if old_requester != requester {
self.policies.clear_policy(&old_requester);
}
}
let replaced = self.structured.insert_ticket_assistant(
input.issue_ref,
requester.clone(),
Arc::clone(&session),
);
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,
})
}
}
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {
format!(
"ticket-assistant:{}:{}",
project.id.as_uuid().simple(),
issue_ref.number().get()
)
}
/// Closes an ephemeral ticket assistant chat session.
pub struct CloseTicketAssistant {
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
}
/// 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<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
) -> 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<dyn ProfileStore>,
profile_id: ProfileId,
) -> Result<AgentProfile, AppError> {
profiles
.list()
.await?
.into_iter()
.find(|profile| profile.id == profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {profile_id}")))
}