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:
2026-07-06 06:06:24 +02:00
parent f1803768fd
commit 1fdf62c089
29 changed files with 2173 additions and 49 deletions

View File

@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
use domain::{AgentId, IssueRef, NodeId, SessionId, SessionKind, TerminalSession};
/// Runtime family of a live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -303,6 +303,12 @@ struct StructuredEntry {
node_id: NodeId,
}
#[derive(Clone)]
struct TicketAssistantEntry {
session: Arc<dyn AgentSession>,
requester: String,
}
/// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5).
///
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
@ -318,6 +324,7 @@ struct StructuredEntry {
#[derive(Default)]
pub struct StructuredSessions {
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
ticket_assistants: Mutex<HashMap<IssueRef, TicketAssistantEntry>>,
}
impl LiveAgentRegistry for StructuredSessions {
@ -339,6 +346,7 @@ impl StructuredSessions {
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
ticket_assistants: Mutex::new(HashMap::new()),
}
}
@ -365,6 +373,60 @@ impl StructuredSessions {
.lock()
.ok()
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
.or_else(|| {
self.ticket_assistants.lock().ok().and_then(|m| {
m.values()
.find(|e| e.session.id() == *id)
.map(|e| Arc::clone(&e.session))
})
})
}
/// Enregistre une session assistant ticket éphémère, keyée par `issue_ref`.
///
/// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer
/// hors verrou. Ces sessions ne sont pas des agents manifest et ne participent
/// donc pas aux vues `live_agents`.
pub fn insert_ticket_assistant(
&self,
issue_ref: IssueRef,
requester: String,
session: Arc<dyn AgentSession>,
) -> Option<Arc<dyn AgentSession>> {
self.ticket_assistants
.lock()
.ok()
.and_then(|mut m| m.insert(issue_ref, TicketAssistantEntry { session, requester }))
.map(|entry| entry.session)
}
/// Retourne la session assistant vivante pour `issue_ref`, si présente.
#[must_use]
pub fn ticket_assistant_session(&self, issue_ref: IssueRef) -> Option<Arc<dyn AgentSession>> {
self.ticket_assistants
.lock()
.ok()
.and_then(|m| m.get(&issue_ref).map(|e| Arc::clone(&e.session)))
}
/// Retourne l'identité MCP requester de l'assistant ticket, si présent.
#[must_use]
pub fn ticket_assistant_requester(&self, issue_ref: IssueRef) -> Option<String> {
self.ticket_assistants
.lock()
.ok()
.and_then(|m| m.get(&issue_ref).map(|e| e.requester.clone()))
}
/// Retire l'assistant ticket pour `issue_ref`.
pub fn remove_ticket_assistant(
&self,
issue_ref: IssueRef,
) -> Option<(Arc<dyn AgentSession>, String)> {
self.ticket_assistants
.lock()
.ok()
.and_then(|mut m| m.remove(&issue_ref).map(|e| (e.session, e.requester)))
}
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
@ -460,6 +522,15 @@ impl StructuredSessions {
.lock()
.ok()
.and_then(|mut m| m.remove(id).map(|e| e.session))
.or_else(|| {
self.ticket_assistants.lock().ok().and_then(|mut m| {
let issue_ref = m
.iter()
.find(|(_, e)| e.session.id() == *id)
.map(|(issue_ref, _)| *issue_ref)?;
m.remove(&issue_ref).map(|e| e.session)
})
})
}
/// Retourne toutes les sessions vivantes (pour un arrêt global propre au
@ -468,14 +539,31 @@ impl StructuredSessions {
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
self.entries
.lock()
.map(|m| m.values().map(|e| Arc::clone(&e.session)).collect())
.map(|m| {
m.values()
.map(|e| Arc::clone(&e.session))
.collect::<Vec<_>>()
})
.unwrap_or_default()
.into_iter()
.chain(
self.ticket_assistants
.lock()
.map(|m| {
m.values()
.map(|e| Arc::clone(&e.session))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
)
.collect()
}
/// Nombre de sessions structurées vivantes.
#[must_use]
pub fn len(&self) -> usize {
self.entries.lock().map(|m| m.len()).unwrap_or(0)
+ self.ticket_assistants.lock().map(|m| m.len()).unwrap_or(0)
}
/// Si le registre est vide.