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

@ -35,12 +35,14 @@ import type {
ProfileAvailability,
ResumableAgent,
Skill,
ReplyChunk,
SkillScope,
Sprint,
Template,
TerminalSession,
Ticket,
TicketCarnet,
TicketChat,
TicketLink,
TicketLinkKind,
TicketList,
@ -1816,6 +1818,9 @@ export class MockTicketGateway implements TicketGateway {
private counters = new Map<string, number>();
private sprints = new Map<string, Map<string, Sprint>>();
private sprintCounters = new Map<string, number>();
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
private chatSessions = new Map<string, string>();
private chatCounter = 0;
constructor(private readonly system?: MockSystemGateway) {}
@ -2235,6 +2240,51 @@ export class MockTicketGateway implements TicketGateway {
this.system?.emit({ type: "sprintDeleted", sprintId });
}
async openTicketChat(
projectId: string,
issueRef: string,
profileId: string,
): Promise<TicketChat> {
// Ensure the ticket exists (mirrors the backend resolving the ref).
this.require(projectId, issueRef);
const sessionId = `mock-ticket-chat-${++this.chatCounter}`;
this.chatSessions.set(issueRef, sessionId);
this.system?.emit({ type: "ticketAssistantOpened", issueRef, profileId });
return { sessionId, requester: "ticket-assistant", issueRef };
}
async closeTicketChat(_projectId: string, issueRef: string): Promise<void> {
if (this.chatSessions.delete(issueRef)) {
this.system?.emit({ type: "ticketAssistantClosed", issueRef });
}
}
async sendTicketChat(
sessionId: string,
message: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
if (![...this.chatSessions.values()].includes(sessionId)) {
throw {
code: "NOT_FOUND",
message: `chat session ${sessionId} not found`,
} as GatewayError;
}
// Simulate a streamed assistant turn: a couple of text deltas, then the
// deterministic `final` chunk. Delivered across microtasks (like a real
// Channel), so a consumer that awaits sees the whole turn.
const content = `Assistant: reçu « ${message} ».`;
const chunks: ReplyChunk[] = [
{ kind: "textDelta", text: "Assistant: reçu " },
{ kind: "textDelta", text: `« ${message} ».` },
{ kind: "final", content },
];
for (const chunk of chunks) {
await Promise.resolve();
onChunk(chunk);
}
}
private requireSprint(projectId: string, sprintId: string): Sprint {
const sprint = this.projectSprints(projectId).get(sprintId);
if (!sprint) {