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

View File

@ -0,0 +1,110 @@
use std::path::PathBuf;
use std::sync::Arc;
use domain::{
AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority,
IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath,
};
use infrastructure::{FsAssistantContextStore, LocalFileSystem};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-assistant-context-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn app_data_dir(&self) -> String {
self.0.join("app-data").to_string_lossy().into_owned()
}
fn project_root(&self) -> ProjectPath {
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn project(root: ProjectPath) -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)),
"demo",
root,
domain::remote::RemoteRef::local(),
1_000,
)
.unwrap()
}
fn issue(number: u64) -> Issue {
Issue::new(
IssueId::from_uuid(Uuid::from_u128(number as u128)),
IssueNumber::new(number).unwrap(),
"Clarify onboarding",
MarkdownDoc::new("Initial description body"),
IssueStatus::Open,
IssuePriority::High,
MarkdownDoc::new("Carnet notes"),
Vec::new(),
Vec::new(),
IssueActor::User,
1_000,
)
.unwrap()
}
fn store(app_data_dir: String) -> FsAssistantContextStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsAssistantContextStore::new(fs, app_data_dir)
}
#[tokio::test]
async fn default_context_is_embedded_and_injects_the_ticket() {
let tmp = TempDir::new();
let project = project(tmp.project_root());
let store = store(tmp.app_data_dir());
let ctx = store
.prepare_ticket_assistant_context(&project, &issue(7))
.await
.unwrap();
let body = ctx.content.as_str();
assert!(body.contains("# Ticket Assistant"));
assert!(body.contains("- Ref: #7"));
assert!(body.contains("- Title: Clarify onboarding"));
assert!(body.contains("Initial description body"));
assert!(body.contains("Carnet notes"));
assert_eq!(ctx.project_root, project.root.as_str());
assert_eq!(ctx.relative_path, "ticket-assistant.md");
}
#[tokio::test]
async fn app_data_override_replaces_the_embedded_default() {
let tmp = TempDir::new();
let app_data_dir = tmp.app_data_dir();
let override_path = PathBuf::from(&app_data_dir)
.join("assistant")
.join("ticket-assistant.md");
std::fs::create_dir_all(override_path.parent().unwrap()).unwrap();
std::fs::write(&override_path, "# Custom assistant").unwrap();
let project = project(tmp.project_root());
let store = store(app_data_dir);
let ctx = store
.prepare_ticket_assistant_context(&project, &issue(8))
.await
.unwrap();
let body = ctx.content.as_str();
assert!(body.starts_with("# Custom assistant"));
assert!(!body.contains("You are IdeA's ticket editing assistant"));
assert!(body.contains("- Ref: #8"));
}

View File

@ -21,6 +21,7 @@
//! `OrchestratorRequest::validate` (typed error, **no** dispatch).
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
@ -44,7 +45,7 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId};
use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId};
use uuid::Uuid;
use application::{
@ -55,7 +56,7 @@ use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock,
SystemMillisClock, ToolPolicyRegistry,
};
use serde_json::{json, Value};
@ -591,6 +592,73 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
}
}
#[tokio::test]
async fn tools_list_is_filtered_for_requester_with_tool_policy() {
let (service, _s) = build_service(FakeContexts::new());
let registry = Arc::new(ToolPolicyRegistry::new());
registry.set(
"assistant-req",
AgentToolPolicy::new(
vec![
"idea_ticket_read".to_owned(),
"idea_ticket_update".to_owned(),
"idea_ticket_update_carnet".to_owned(),
],
Some(IssueRef::from_str("#7").unwrap()),
true,
),
);
let server = server(service)
.with_tool_policies(registry)
.for_requester("assistant-req");
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "got error: {:?}", response.error);
let result = response.result.expect("result");
let tools = result["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(
names,
vec![
"idea_ticket_read",
"idea_ticket_update",
"idea_ticket_update_carnet"
]
);
}
#[tokio::test]
async fn requester_without_tool_policy_keeps_the_full_tools_list() {
let (service, _s) = build_service(FakeContexts::new());
let registry = Arc::new(ToolPolicyRegistry::new());
let server = server(service)
.with_tool_policies(registry)
.for_requester("ordinary-agent");
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "got error: {:?}", response.error);
let result = response.result.expect("result");
let names: Vec<&str> = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"idea_ask_agent"));
assert!(names.contains(&"idea_ticket_update"));
assert!(names.len() > 3, "unfiltered requester got {names:?}");
}
// ---------------------------------------------------------------------------
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
// ---------------------------------------------------------------------------
@ -685,6 +753,82 @@ async fn update_context_and_create_skill_calls_succeed() {
assert_eq!(r.result.expect("result")["isError"], json!(false));
}
#[tokio::test]
async fn tools_call_outside_allowlist_is_rejected_for_policy_requester() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let registry = Arc::new(ToolPolicyRegistry::new());
registry.set(
"assistant-req",
AgentToolPolicy::new(
vec!["idea_ticket_read".to_owned()],
Some(IssueRef::from_str("#7").unwrap()),
true,
),
);
let server = server(service)
.with_tool_policies(registry)
.for_requester("assistant-req");
let raw = tools_call(
11,
"idea_ask_agent",
json!({ "target": "architect", "task": "please handle this" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
let error = response.error.expect("policy rejection expected");
assert_eq!(error.code, error_codes::INVALID_PARAMS);
assert!(
error.message.contains("not permitted"),
"got {}",
error.message
);
assert!(response.result.is_none());
}
#[tokio::test]
async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() {
let (service, _s) = build_service(FakeContexts::new());
let registry = Arc::new(ToolPolicyRegistry::new());
registry.set(
"assistant-req",
AgentToolPolicy::new(
vec!["idea_ticket_update".to_owned()],
Some(IssueRef::from_str("#7").unwrap()),
true,
),
);
let ticket_tools = Arc::new(FakeTicketTools::default());
let server = server(service)
.with_ticket_tools(ticket_tools.clone())
.with_tool_policies(registry)
.for_requester("assistant-req");
let raw = tools_call(
12,
"idea_ticket_update",
json!({
"ref": "#8",
"expectedVersion": 1,
"title": "must not pass"
}),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
let error = response.error.expect("policy rejection expected");
assert_eq!(error.code, error_codes::INVALID_PARAMS);
assert!(
error.message.contains("#8"),
"message should name the rejected issue; got {}",
error.message
);
assert!(
ticket_tools.calls().is_empty(),
"policy rejection must happen before ticket provider dispatch"
);
assert_eq!(ticket_tools.mutation_attempts(), 0);
}
// ---------------------------------------------------------------------------
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not
// ---------------------------------------------------------------------------