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,339 @@
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use application::{
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
StructuredSessions,
};
use async_trait::async_trait;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan,
};
use domain::profile::StructuredAdapter;
use domain::{
AgentProfile, AgentToolPolicy, AgentToolPolicyStore, AssistantContextError,
AssistantContextProvider, ContextInjection, DomainEvent, EventBus, EventStream, Issue,
IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef,
IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, PreparedContext,
ProfileId, ProfileStore, Project, ProjectId, ProjectPath, RemoteRef, SessionId, StoreError,
};
use uuid::Uuid;
fn issue_ref(raw: &str) -> IssueRef {
IssueRef::from_str(raw).unwrap()
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/project").unwrap(),
RemoteRef::local(),
1_000,
)
.unwrap()
}
fn profile(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"Claude",
"claude",
Vec::new(),
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
}
fn issue(number: u64) -> Issue {
Issue::new(
IssueId::from_uuid(Uuid::from_u128(number as u128)),
IssueNumber::new(number).unwrap(),
"Ticket title",
MarkdownDoc::new("Ticket description"),
IssueStatus::Open,
IssuePriority::High,
MarkdownDoc::new("Ticket carnet"),
Vec::new(),
Vec::new(),
IssueActor::User,
1_000,
)
.unwrap()
}
struct FakeIssues {
issue: Issue,
}
#[async_trait]
impl IssueStore for FakeIssues {
async fn create(&self, _root: &ProjectPath, _issue: &Issue) -> Result<(), IssueStoreError> {
unimplemented!()
}
async fn get_by_ref(
&self,
_root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<Issue, IssueStoreError> {
if self.issue.reference() == issue_ref {
Ok(self.issue.clone())
} else {
Err(IssueStoreError::NotFound)
}
}
async fn list(
&self,
_root: &ProjectPath,
_filter: IssueListFilter,
) -> Result<Vec<domain::IssueIndexEntry>, IssueStoreError> {
Ok(Vec::new())
}
async fn update(
&self,
_root: &ProjectPath,
_issue: &Issue,
_expected_version: IssueVersion,
) -> Result<(), IssueStoreError> {
unimplemented!()
}
async fn read_carnet(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
) -> Result<IssueCarnet, IssueStoreError> {
unimplemented!()
}
async fn write_carnet(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_carnet: MarkdownDoc,
_actor: IssueActor,
_now_ms: u64,
_expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError> {
unimplemented!()
}
}
struct FakeProfiles {
profile: AgentProfile,
}
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(vec![self.profile.clone()])
}
async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
Ok(())
}
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeAssistantContext {
prepared: Mutex<Option<PreparedContext>>,
}
#[async_trait]
impl AssistantContextProvider for FakeAssistantContext {
async fn prepare_ticket_assistant_context(
&self,
project: &Project,
issue: &Issue,
) -> Result<PreparedContext, AssistantContextError> {
let ctx = PreparedContext {
content: MarkdownDoc::new(format!(
"assistant\n{}\n{}\n{}",
issue.reference(),
issue.description.as_str(),
issue.carnet.as_str()
)),
relative_path: "ticket-assistant.md".to_owned(),
project_root: project.root.as_str().to_owned(),
};
*self.prepared.lock().unwrap() = Some(ctx.clone());
Ok(ctx)
}
}
struct FakeSession {
id: SessionId,
shutdowns: Arc<Mutex<usize>>,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
*self.shutdowns.lock().unwrap() += 1;
Ok(())
}
}
#[derive(Default)]
struct FakeFactory {
starts: Mutex<Vec<(PreparedContext, SessionPlan, Option<domain::SandboxPlan>)>>,
shutdowns: Arc<Mutex<usize>>,
}
#[async_trait]
impl AgentSessionFactory for FakeFactory {
fn supports(&self, _profile: &AgentProfile) -> bool {
true
}
async fn start(
&self,
_profile: &AgentProfile,
ctx: &PreparedContext,
_cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&domain::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts
.lock()
.unwrap()
.push((ctx.clone(), session.clone(), sandbox.cloned()));
Ok(Arc::new(FakeSession {
id: SessionId::new_random(),
shutdowns: Arc::clone(&self.shutdowns),
}))
}
}
#[derive(Default)]
struct FakePolicies(Mutex<HashMap<String, AgentToolPolicy>>);
impl AgentToolPolicyStore for FakePolicies {
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
self.0.lock().unwrap().insert(requester, policy);
}
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
self.0.lock().unwrap().get(requester).cloned()
}
fn clear_policy(&self, requester: &str) {
self.0.lock().unwrap().remove(requester);
}
}
#[derive(Default)]
struct SpyBus(Mutex<Vec<DomainEvent>>);
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
#[tokio::test]
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
let issue = issue(7);
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
let structured = Arc::new(StructuredSessions::new());
let policies = Arc::new(FakePolicies::default());
let events = Arc::new(SpyBus::default());
let context = Arc::new(FakeAssistantContext::default());
let factory = Arc::new(FakeFactory::default());
let open = OpenTicketAssistant::new(
Arc::new(FakeIssues {
issue: issue.clone(),
}),
Arc::new(FakeProfiles {
profile: profile(profile_id),
}),
context.clone(),
factory.clone(),
structured.clone(),
policies.clone(),
events.clone(),
);
let close = CloseTicketAssistant::new(structured.clone(), policies.clone(), events.clone());
let output = open
.execute(OpenTicketAssistantInput {
project: project(),
issue_ref: issue.reference(),
profile_id,
})
.await
.unwrap();
assert!(structured.session(&output.session_id).is_some());
let policy = policies.get_policy(&output.requester).expect("policy set");
assert!(policy.permits("idea_ticket_read"));
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue.reference()));
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
let prepared = context.prepared.lock().unwrap().clone().unwrap();
assert!(prepared.content.as_str().contains("#7"));
assert!(prepared.content.as_str().contains("Ticket description"));
assert!(prepared.content.as_str().contains("Ticket carnet"));
let starts = factory.starts.lock().unwrap();
assert!(matches!(starts[0].1, SessionPlan::None));
assert!(starts[0]
.2
.as_ref()
.is_some_and(|p| { p.default_posture == domain::permission::Posture::Deny }));
drop(starts);
close
.execute(CloseTicketAssistantInput {
issue_ref: issue.reference(),
})
.await
.unwrap();
assert!(structured.session(&output.session_id).is_none());
assert!(policies.get_policy(&output.requester).is_none());
assert_eq!(*factory.shutdowns.lock().unwrap(), 1);
let events = events.0.lock().unwrap();
assert!(matches!(
events[0],
DomainEvent::TicketAssistantOpened { issue_ref, .. } if issue_ref == issue.reference()
));
assert!(matches!(
events[1],
DomainEvent::TicketAssistantClosed { issue_ref } if issue_ref == issue.reference()
));
}