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

@ -0,0 +1,5 @@
# Ticket Assistant
You are IdeA's ticket editing assistant. Help improve the bound ticket only.
Read the project context when needed, propose concise changes, and use ticket
tools only for the ticket explicitly injected below.

View File

@ -0,0 +1,76 @@
//! Ticket assistant infrastructure adapters.
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc,
PreparedContext, Project, RemotePath,
};
const ASSISTANT_DIR: &str = "assistant";
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");
/// File-backed provider for the IdeA-owned ticket assistant context.
#[derive(Clone)]
pub struct FsAssistantContextStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsAssistantContextStore {
/// Builds the store from an injected filesystem and app-data directory.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
fn join(&self, rel: &str) -> String {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}
fn context_path(&self) -> RemotePath {
RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}")))
}
async fn read_base_context(&self) -> Result<String, AssistantContextError> {
let path = self.context_path();
match self.fs.read(&path).await {
Ok(bytes) => {
String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string()))
}
Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()),
Err(e) => Err(AssistantContextError::Store(e.to_string())),
}
}
}
#[async_trait]
impl AssistantContextProvider for FsAssistantContextStore {
async fn prepare_ticket_assistant_context(
&self,
project: &Project,
issue: &Issue,
) -> Result<PreparedContext, AssistantContextError> {
let mut content = self.read_base_context().await?;
content.push_str("\n\n## Bound Ticket\n\n");
content.push_str(&format!("- Ref: {}\n", issue.reference()));
content.push_str(&format!("- Title: {}\n", issue.title));
content.push_str("\n### Description\n\n");
content.push_str(issue.description.as_str());
content.push_str("\n\n### Carnet\n\n");
content.push_str(issue.carnet.as_str());
content.push('\n');
Ok(PreparedContext {
content: MarkdownDoc::new(content),
relative_path: TICKET_ASSISTANT_FILE.to_owned(),
project_root: project.root.as_str().to_owned(),
})
}
}

View File

@ -12,6 +12,7 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod assistant;
pub mod background_task;
pub mod clock;
pub mod conversation;
@ -39,6 +40,7 @@ pub mod sprints;
pub mod store;
pub mod timeparse;
pub use assistant::FsAssistantContextStore;
pub use background_task::{
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
@ -64,6 +66,7 @@ pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
ToolPolicyRegistry,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

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