feat(tickets): création d'un assistant IA de ticket (backend + frontend)
Ajoute le chat assistant IA attaché à un ticket (#8). Backend Rust : - use cases OpenTicketAssistant/CloseTicketAssistant + tests - politique d'outils par agent (domain/agent_tool_policy) et policy MCP - store de contexte assistant + gabarit default_ticket_assistant.md + tests - events TicketAssistantOpened/Closed - commandes Tauri open_ticket_chat/close_ticket_chat et câblage state/lib/events Frontend : - gateway (ports, adapters ticket + mock, domain) - hook useTicketAssistant + composant TicketAssistantPanel - intégration dans TicketDetail Tests verts : vitest tickets.test.tsx (23), cargo test application::ticket_assistant (1), infrastructure::assistant_context_store (2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
222
crates/application/src/ticket_assistant.rs
Normal file
222
crates/application/src/ticket_assistant.rs
Normal file
@ -0,0 +1,222 @@
|
||||
//! Use cases for ephemeral AI ticket editing assistants.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentSessionFactory, SessionPlan};
|
||||
use domain::{AgentProfile, ProjectPath};
|
||||
use domain::{
|
||||
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
|
||||
IssueRef, IssueStore, ProfileId, ProfileStore, Project, SandboxPlan, 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>,
|
||||
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>,
|
||||
factory: Arc<dyn AgentSessionFactory>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> 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<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 sandbox = ticket_assistant_sandbox(&input.project.root, input.issue_ref);
|
||||
let session = self
|
||||
.factory
|
||||
.start(
|
||||
&profile,
|
||||
&prepared,
|
||||
&input.project.root,
|
||||
&SessionPlan::None,
|
||||
Some(&sandbox),
|
||||
)
|
||||
.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<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}")))
|
||||
}
|
||||
|
||||
fn ticket_assistant_sandbox(project_root: &ProjectPath, issue_ref: IssueRef) -> SandboxPlan {
|
||||
let run_dir = format!(
|
||||
"{}/.ideai/run/ticket-assistant-{}",
|
||||
project_root.as_str().trim_end_matches(['/', '\\']),
|
||||
issue_ref.number().get()
|
||||
);
|
||||
SandboxPlan::project_read_only(project_root.as_str().to_owned(), run_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sandbox_uses_project_read_only_preset() {
|
||||
let root = ProjectPath::new("/tmp/project").unwrap();
|
||||
let issue_ref = IssueRef::from(domain::IssueNumber::new(7).unwrap());
|
||||
let plan = ticket_assistant_sandbox(&root, issue_ref);
|
||||
assert_eq!(plan.allowed[0].abs_root, "/tmp/project");
|
||||
assert_eq!(plan.default_posture, domain::permission::Posture::Deny);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user