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

@ -29,6 +29,7 @@
//! child process).
pub mod jsonrpc;
pub mod policy;
pub mod server;
pub mod tickets;
pub mod tools;
@ -37,6 +38,7 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use policy::ToolPolicyRegistry;
pub use server::McpServer;
pub use tickets::{TicketToolError, TicketToolProvider};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};

View File

@ -0,0 +1,76 @@
//! In-memory MCP tool policy registry.
use std::collections::HashMap;
use std::sync::RwLock;
use domain::AgentToolPolicy;
use domain::AgentToolPolicyStore;
/// Stores per-requester MCP tool policies for live assistant sessions.
#[derive(Default)]
pub struct ToolPolicyRegistry {
policies: RwLock<HashMap<String, AgentToolPolicy>>,
}
impl ToolPolicyRegistry {
/// Builds an empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets or replaces the policy for `requester`.
pub fn set(&self, requester: impl Into<String>, policy: AgentToolPolicy) {
self.policies
.write()
.unwrap()
.insert(requester.into(), policy);
}
/// Returns the policy for `requester`, if any.
#[must_use]
pub fn get(&self, requester: &str) -> Option<AgentToolPolicy> {
self.policies.read().unwrap().get(requester).cloned()
}
/// Clears the policy for `requester`.
pub fn clear(&self, requester: &str) {
self.policies.write().unwrap().remove(requester);
}
}
impl AgentToolPolicyStore for ToolPolicyRegistry {
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
self.set(requester, policy);
}
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
self.get(requester)
}
fn clear_policy(&self, requester: &str) {
self.clear(requester);
}
}
#[cfg(test)]
mod tests {
use domain::IssueRef;
use super::*;
#[test]
fn set_get_clear_roundtrips_by_requester() {
let registry = ToolPolicyRegistry::new();
let policy = AgentToolPolicy::new(
vec!["idea_ticket_read".to_owned()],
Some("#7".parse::<IssueRef>().unwrap()),
true,
);
registry.set("assistant-1", policy.clone());
assert_eq!(registry.get("assistant-1"), Some(policy));
assert_eq!(registry.get("assistant-2"), None);
registry.clear("assistant-1");
assert_eq!(registry.get("assistant-1"), None);
}
}

View File

@ -16,11 +16,12 @@
//! at the composition root (M3), exactly like the watcher — no application logic is
//! duplicated here.
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use application::OrchestratorService;
use domain::{DomainEvent, OrchestrationSource, Project};
use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project};
use serde_json::{json, Value};
use tokio::sync::mpsc;
@ -28,6 +29,7 @@ use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::policy::ToolPolicyRegistry;
use super::tickets::TicketToolProvider;
use super::tools::{self, ToolMapError};
@ -65,6 +67,8 @@ pub struct McpServer {
/// Optional public ticket provider. The MCP surface says `ticket`; the
/// provider maps those calls to application/domain `Issue` use cases.
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
}
impl McpServer {
@ -80,6 +84,7 @@ impl McpServer {
requester: String::new(),
ready_sink: None,
ticket_tools: None,
tool_policies: None,
}
}
@ -111,6 +116,13 @@ impl McpServer {
self
}
/// Attaches the requester-scoped MCP tool policy registry.
#[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
self.tool_policies = Some(tool_policies);
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
@ -128,6 +140,7 @@ impl McpServer {
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
tool_policies: self.tool_policies.clone(),
}
}
@ -300,8 +313,17 @@ impl McpServer {
/// The `tools/list` result: the catalogue as MCP tool descriptors.
fn tools_list_result(&self) -> Value {
let policy = self
.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester));
let tools: Vec<Value> = tools::catalogue()
.into_iter()
.filter(|t| {
policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
})
.map(|t| {
json!({
"name": t.name,
@ -324,6 +346,14 @@ impl McpServer {
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
if let Some(policy) = self
.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester))
{
self.enforce_tool_policy(&policy, &name, &arguments)?;
}
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
let started = Instant::now();
@ -437,6 +467,47 @@ impl McpServer {
}
}
fn enforce_tool_policy(
&self,
policy: &AgentToolPolicy,
name: &str,
arguments: &Value,
) -> Result<(), JsonRpcError> {
if !policy.permits(name) {
return Err(JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!(
"tool `{name}` is not permitted for requester {}",
self.requester
),
));
}
if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") {
let raw_ref = arguments
.get("ref")
.and_then(Value::as_str)
.ok_or_else(|| {
JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("tool `{name}` requires a ticket ref under the active policy"),
)
})?;
let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| {
JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("invalid ticket ref: {e}"),
)
})?;
if !policy.permits_ticket_mutation(name, issue_ref) {
return Err(JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("tool `{name}` is not permitted for ticket {issue_ref}"),
));
}
}
Ok(())
}
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5