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,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
// ---------------------------------------------------------------------------