From 1fdf62c089ba1d907ff810d8d8e90e5be20229b2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 6 Jul 2026 06:06:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20cr=C3=A9ation=20d'un=20assista?= =?UTF-8?q?nt=20IA=20de=20ticket=20(backend=20+=20frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/events.rs | 44 +++ crates/app-tauri/src/lib.rs | 2 + crates/app-tauri/src/state.rs | 103 ++++-- crates/app-tauri/src/tickets.rs | 79 +++- crates/application/src/lib.rs | 5 + crates/application/src/terminal/registry.rs | 92 ++++- crates/application/src/ticket_assistant.rs | 222 ++++++++++++ crates/application/tests/ticket_assistant.rs | 339 ++++++++++++++++++ crates/domain/src/agent_tool_policy.rs | 113 ++++++ crates/domain/src/events.rs | 61 ++++ crates/domain/src/lib.rs | 6 +- crates/domain/src/ports.rs | 38 ++ crates/domain/src/sandbox.rs | 38 ++ .../src/assistant/default_ticket_assistant.md | 5 + crates/infrastructure/src/assistant/mod.rs | 76 ++++ crates/infrastructure/src/lib.rs | 3 + .../src/orchestrator/mcp/mod.rs | 2 + .../src/orchestrator/mcp/policy.rs | 76 ++++ .../src/orchestrator/mcp/server.rs | 73 +++- .../tests/assistant_context_store.rs | 110 ++++++ crates/infrastructure/tests/mcp_server.rs | 148 +++++++- frontend/src/adapters/mock/index.ts | 50 +++ frontend/src/adapters/ticket.ts | 38 +- frontend/src/domain/index.ts | 37 ++ .../features/tickets/TicketAssistantPanel.tsx | 178 +++++++++ .../src/features/tickets/TicketDetail.tsx | 9 + .../src/features/tickets/tickets.test.tsx | 73 ++++ .../features/tickets/useTicketAssistant.ts | 177 +++++++++ frontend/src/ports/index.ts | 25 ++ 29 files changed, 2173 insertions(+), 49 deletions(-) create mode 100644 crates/application/src/ticket_assistant.rs create mode 100644 crates/application/tests/ticket_assistant.rs create mode 100644 crates/domain/src/agent_tool_policy.rs create mode 100644 crates/infrastructure/src/assistant/default_ticket_assistant.md create mode 100644 crates/infrastructure/src/assistant/mod.rs create mode 100644 crates/infrastructure/src/orchestrator/mcp/policy.rs create mode 100644 crates/infrastructure/tests/assistant_context_store.rs create mode 100644 frontend/src/features/tickets/TicketAssistantPanel.tsx create mode 100644 frontend/src/features/tickets/useTicketAssistant.ts diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 20a7734..1af1e22 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -50,6 +50,20 @@ pub enum DomainEventDto { /// Exit code. code: i32, }, + /// A ticket assistant chat was opened. + #[serde(rename_all = "camelCase")] + TicketAssistantOpened { + /// Bound ticket reference. + issue_ref: String, + /// Runtime profile id. + profile_id: String, + }, + /// A ticket assistant chat was closed. + #[serde(rename_all = "camelCase")] + TicketAssistantClosed { + /// Bound ticket reference. + issue_ref: String, + }, /// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims /// "Envoyer" while `busy` is `true`. #[serde(rename_all = "camelCase")] @@ -551,6 +565,16 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), code: *code, }, + DomainEvent::TicketAssistantOpened { + issue_ref, + profile_id, + } => Self::TicketAssistantOpened { + issue_ref: issue_ref.to_string(), + profile_id: profile_id.to_string(), + }, + DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed { + issue_ref: issue_ref.to_string(), + }, DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged { agent_id: agent_id.to_string(), busy: *busy, @@ -1081,4 +1105,24 @@ mod tests { assert_eq!(json["to"], to.to_string()); assert_eq!(json["version"], 3); } + + #[test] + fn ticket_assistant_events_relay_to_dto_and_wire() { + let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9)); + let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened { + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + profile_id, + }); + let opened = serde_json::to_value(&opened).expect("serialisable"); + assert_eq!(opened["type"], "ticketAssistantOpened"); + assert_eq!(opened["issueRef"], "#7"); + assert_eq!(opened["profileId"], profile_id.to_string()); + + let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed { + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + }); + let closed = serde_json::to_value(&closed).expect("serialisable"); + assert_eq!(closed["type"], "ticketAssistantClosed"); + assert_eq!(closed["issueRef"], "#7"); + } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 3323ac7..bb77fe3 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -166,6 +166,8 @@ pub fn run() { commands::list_agents, tickets::ticket_create, tickets::ticket_read, + tickets::open_ticket_chat, + tickets::close_ticket_chat, tickets::ticket_list, tickets::ticket_update, tickets::ticket_read_carnet, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 2065036..b72d1f1 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -15,39 +15,40 @@ use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, - ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, - CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, - CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, - DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, - DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, - GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, - InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, - ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, - ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, - RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, - RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, - UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, + CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, + CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, + DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, + DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, + GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, + GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, + HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, + ListAgentsInput, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, + ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, + LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, + SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, + UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, + UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, + UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ - AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort, - BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, Embedder, - EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, - IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, - ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, - SprintStore, TemplateStore, WakeError, WakeReason, + AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, + AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, + BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, + EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, + IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, + ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore, + WakeError, WakeReason, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -65,16 +66,16 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, - FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore, - FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, + FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, + FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, + FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, - UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, - RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; use crate::chat::ChatBridge; @@ -822,6 +823,12 @@ pub struct AppState { pub unassign_ticket_from_sprint: Arc, /// MCP provider for the public `idea_ticket_*` tools. pub(crate) ticket_tool_provider: Arc, + /// Open an ephemeral AI assistant chat bound to one ticket. + pub open_ticket_assistant: Arc, + /// Close an ephemeral AI assistant chat bound to one ticket. + pub close_ticket_assistant: Arc, + /// Shared MCP tool policy registry used by ticket assistant sessions. + pub tool_policy_registry: Arc, /// Generic PTY↔Channel bridge registry (consumed by L3). pub pty_bridge: Arc, /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec @@ -1279,6 +1286,26 @@ impl AppState { unlink: Arc::clone(&unlink_issues), list_sprints: Arc::clone(&list_sprints), }); + let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); + let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc; + let assistant_context_provider = Arc::new(FsAssistantContextStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )) as Arc; + let open_ticket_assistant = Arc::new(OpenTicketAssistant::new( + Arc::clone(&issue_store_port), + Arc::clone(&profile_store_port), + assistant_context_provider, + Arc::clone(&session_factory), + Arc::clone(&structured_sessions), + Arc::clone(&tool_policy_store), + Arc::clone(&events_port), + )); + let close_ticket_assistant = Arc::new(CloseTicketAssistant::new( + Arc::clone(&structured_sessions), + tool_policy_store, + Arc::clone(&events_port), + )); // --- Project permissions (LP1) --- let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); @@ -2178,6 +2205,9 @@ impl AppState { assign_ticket_to_sprint, unassign_ticket_from_sprint, ticket_tool_provider, + open_ticket_assistant, + close_ticket_assistant, + tool_policy_registry, pty_bridge, structured_sessions, chat_bridge, @@ -2337,7 +2367,8 @@ impl AppState { McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) .with_events(events) .with_ready_sink(ready_sink) - .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)), + .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) + .with_tool_policies(Arc::clone(&self.tool_policy_registry)), endpoint, listener, project_id, diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index 52194b3..f1c73f3 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -14,15 +14,16 @@ use tauri::State; use uuid::Uuid; use application::{ - AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput, - CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput, - OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput, - UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, + AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, + CreateIssueInput, CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, + ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput, + ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput, + UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, }; use domain::{ AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, - IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project, - ProjectId, SprintId, SprintStatus, SprintVersion, + IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, + Project, ProjectId, SprintId, SprintStatus, SprintVersion, }; use infrastructure::{TicketToolError, TicketToolProvider}; @@ -106,6 +107,29 @@ pub struct SprintListDto { pub items: Vec, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenTicketChatRequestDto { + pub project_id: String, + pub issue_ref: String, + pub profile_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseTicketChatRequestDto { + pub project_id: String, + pub issue_ref: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketChatDto { + pub session_id: String, + pub requester: String, + pub issue_ref: String, +} + /// Public link DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -588,6 +612,43 @@ pub async fn ticket_read( Ok(TicketDto::from_issue(issue, carnet)) } +#[tauri::command] +pub async fn open_ticket_chat( + request: OpenTicketChatRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let output = state + .open_ticket_assistant + .execute(OpenTicketAssistantInput { + project, + issue_ref: parse_ref_dto(&request.issue_ref)?, + profile_id: parse_profile_id_dto(&request.profile_id)?, + }) + .await + .map_err(ErrorDto::from)?; + Ok(TicketChatDto { + session_id: output.session_id.to_string(), + requester: output.requester, + issue_ref: output.issue_ref.to_string(), + }) +} + +#[tauri::command] +pub async fn close_ticket_chat( + request: CloseTicketChatRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let _project = resolve_project(&state, &request.project_id).await?; + state + .close_ticket_assistant + .execute(CloseTicketAssistantInput { + issue_ref: parse_ref_dto(&request.issue_ref)?, + }) + .await + .map_err(ErrorDto::from) +} + #[tauri::command] pub async fn ticket_list( request: TicketListRequestDto, @@ -1234,6 +1295,12 @@ fn parse_agent_id_dto(raw: &str) -> Result { .map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}"))) } +fn parse_profile_id_dto(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(ProfileId::from_uuid) + .map_err(|e| ErrorDto::invalid(format!("invalid profile id: {e}"))) +} + fn parse_sprint_id_dto(raw: &str) -> Result { Uuid::parse_str(raw) .map(SprintId::from_uuid) diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index b7c0c33..59c0d20 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -30,6 +30,7 @@ pub mod skill; pub mod sprints; pub mod template; pub mod terminal; +pub mod ticket_assistant; pub mod window; pub mod workstate; @@ -149,6 +150,10 @@ pub use terminal::{ ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput, }; +pub use ticket_assistant::{ + CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput, + OpenTicketAssistantOutput, +}; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; pub use workstate::{ AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent, diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index a45e803..d357775 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; use domain::conversation::ConversationId; use domain::ports::{AgentSession, PtyHandle}; -use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession}; +use domain::{AgentId, IssueRef, NodeId, SessionId, SessionKind, TerminalSession}; /// Runtime family of a live agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -303,6 +303,12 @@ struct StructuredEntry { node_id: NodeId, } +#[derive(Clone)] +struct TicketAssistantEntry { + session: Arc, + requester: String, +} + /// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5). /// /// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas @@ -318,6 +324,7 @@ struct StructuredEntry { #[derive(Default)] pub struct StructuredSessions { entries: Mutex>, + ticket_assistants: Mutex>, } impl LiveAgentRegistry for StructuredSessions { @@ -339,6 +346,7 @@ impl StructuredSessions { pub fn new() -> Self { Self { entries: Mutex::new(HashMap::new()), + ticket_assistants: Mutex::new(HashMap::new()), } } @@ -365,6 +373,60 @@ impl StructuredSessions { .lock() .ok() .and_then(|m| m.get(id).map(|e| Arc::clone(&e.session))) + .or_else(|| { + self.ticket_assistants.lock().ok().and_then(|m| { + m.values() + .find(|e| e.session.id() == *id) + .map(|e| Arc::clone(&e.session)) + }) + }) + } + + /// Enregistre une session assistant ticket éphémère, keyée par `issue_ref`. + /// + /// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer + /// hors verrou. Ces sessions ne sont pas des agents manifest et ne participent + /// donc pas aux vues `live_agents`. + pub fn insert_ticket_assistant( + &self, + issue_ref: IssueRef, + requester: String, + session: Arc, + ) -> Option> { + self.ticket_assistants + .lock() + .ok() + .and_then(|mut m| m.insert(issue_ref, TicketAssistantEntry { session, requester })) + .map(|entry| entry.session) + } + + /// Retourne la session assistant vivante pour `issue_ref`, si présente. + #[must_use] + pub fn ticket_assistant_session(&self, issue_ref: IssueRef) -> Option> { + self.ticket_assistants + .lock() + .ok() + .and_then(|m| m.get(&issue_ref).map(|e| Arc::clone(&e.session))) + } + + /// Retourne l'identité MCP requester de l'assistant ticket, si présent. + #[must_use] + pub fn ticket_assistant_requester(&self, issue_ref: IssueRef) -> Option { + self.ticket_assistants + .lock() + .ok() + .and_then(|m| m.get(&issue_ref).map(|e| e.requester.clone())) + } + + /// Retire l'assistant ticket pour `issue_ref`. + pub fn remove_ticket_assistant( + &self, + issue_ref: IssueRef, + ) -> Option<(Arc, String)> { + self.ticket_assistants + .lock() + .ok() + .and_then(|mut m| m.remove(&issue_ref).map(|e| (e.session, e.requester))) } /// Retourne la session vivante hébergeant `agent_id`, si elle existe. @@ -460,6 +522,15 @@ impl StructuredSessions { .lock() .ok() .and_then(|mut m| m.remove(id).map(|e| e.session)) + .or_else(|| { + self.ticket_assistants.lock().ok().and_then(|mut m| { + let issue_ref = m + .iter() + .find(|(_, e)| e.session.id() == *id) + .map(|(issue_ref, _)| *issue_ref)?; + m.remove(&issue_ref).map(|e| e.session) + }) + }) } /// Retourne toutes les sessions vivantes (pour un arrêt global propre au @@ -468,14 +539,31 @@ impl StructuredSessions { pub fn sessions(&self) -> Vec> { self.entries .lock() - .map(|m| m.values().map(|e| Arc::clone(&e.session)).collect()) + .map(|m| { + m.values() + .map(|e| Arc::clone(&e.session)) + .collect::>() + }) .unwrap_or_default() + .into_iter() + .chain( + self.ticket_assistants + .lock() + .map(|m| { + m.values() + .map(|e| Arc::clone(&e.session)) + .collect::>() + }) + .unwrap_or_default(), + ) + .collect() } /// Nombre de sessions structurées vivantes. #[must_use] pub fn len(&self) -> usize { self.entries.lock().map(|m| m.len()).unwrap_or(0) + + self.ticket_assistants.lock().map(|m| m.len()).unwrap_or(0) } /// Si le registre est vide. diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs new file mode 100644 index 0000000..5c8cb11 --- /dev/null +++ b/crates/application/src/ticket_assistant.rs @@ -0,0 +1,222 @@ +//! Use cases for ephemeral AI ticket editing assistants. + +use std::sync::Arc; + +use domain::ports::{AgentSessionFactory, SessionPlan}; +use domain::{AgentProfile, ProjectPath}; +use domain::{ + AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus, + IssueRef, IssueStore, ProfileId, ProfileStore, Project, SandboxPlan, SessionId, +}; + +use crate::terminal::StructuredSessions; +use crate::AppError; + +/// Opens an ephemeral ticket assistant chat session. +pub struct OpenTicketAssistant { + issues: Arc, + profiles: Arc, + contexts: Arc, + factory: Arc, + structured: Arc, + policies: Arc, + events: Arc, +} + +/// Input for [`OpenTicketAssistant`]. +pub struct OpenTicketAssistantInput { + /// Project owning the ticket. + pub project: Project, + /// Ticket reference to bind. + pub issue_ref: IssueRef, + /// Structured profile to use. + pub profile_id: ProfileId, +} + +/// Output of [`OpenTicketAssistant`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenTicketAssistantOutput { + /// Live structured session id, to be driven by `agent_send`. + pub session_id: SessionId, + /// MCP requester identity bound to this assistant. + pub requester: String, + /// Bound ticket reference. + pub issue_ref: IssueRef, +} + +impl OpenTicketAssistant { + /// Builds the use case. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + issues: Arc, + profiles: Arc, + contexts: Arc, + factory: Arc, + structured: Arc, + policies: Arc, + events: Arc, + ) -> Self { + Self { + issues, + profiles, + contexts, + factory, + structured, + policies, + events, + } + } + + /// Executes the open flow. + /// + /// Reopening an already-live assistant for the same ticket replaces the old + /// session after the fresh one has started. This guarantees the assistant sees + /// the latest ticket content/profile and keeps the invariant "one assistant per + /// ticket" without leaving a gap if the new launch fails. + pub async fn execute( + &self, + input: OpenTicketAssistantInput, + ) -> Result { + let issue = self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?; + let profile = find_profile(&self.profiles, input.profile_id).await?; + if !self.factory.supports(&profile) { + return Err(AppError::Invalid(format!( + "profile {} cannot start a structured ticket assistant", + input.profile_id + ))); + } + let prepared = self + .contexts + .prepare_ticket_assistant_context(&input.project, &issue) + .await + .map_err(|e| AppError::Store(e.to_string()))?; + let sandbox = ticket_assistant_sandbox(&input.project.root, input.issue_ref); + let session = self + .factory + .start( + &profile, + &prepared, + &input.project.root, + &SessionPlan::None, + Some(&sandbox), + ) + .await + .map_err(AppError::from)?; + let session_id = session.id(); + let requester = session_id.to_string(); + let policy = AgentToolPolicy::new( + vec![ + "idea_ticket_read".to_owned(), + "idea_ticket_update".to_owned(), + "idea_ticket_update_carnet".to_owned(), + ], + Some(input.issue_ref), + true, + ); + + if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) { + self.policies.clear_policy(&old_requester); + } + let replaced = self.structured.insert_ticket_assistant( + input.issue_ref, + requester.clone(), + Arc::clone(&session), + ); + self.policies.set_policy(requester.clone(), policy); + if let Some(old) = replaced { + let _ = old.shutdown().await; + } + self.events.publish(DomainEvent::TicketAssistantOpened { + issue_ref: input.issue_ref, + profile_id: input.profile_id, + }); + + Ok(OpenTicketAssistantOutput { + session_id, + requester, + issue_ref: input.issue_ref, + }) + } +} + +/// Closes an ephemeral ticket assistant chat session. +pub struct CloseTicketAssistant { + structured: Arc, + policies: Arc, + events: Arc, +} + +/// Input for [`CloseTicketAssistant`]. +pub struct CloseTicketAssistantInput { + /// Ticket reference whose assistant should be closed. + pub issue_ref: IssueRef, +} + +impl CloseTicketAssistant { + /// Builds the use case. + #[must_use] + pub fn new( + structured: Arc, + policies: Arc, + events: Arc, + ) -> Self { + Self { + structured, + policies, + events, + } + } + + /// Executes the close flow. + pub async fn execute(&self, input: CloseTicketAssistantInput) -> Result<(), AppError> { + let (session, requester) = self + .structured + .remove_ticket_assistant(input.issue_ref) + .ok_or_else(|| AppError::NotFound(format!("ticket assistant {}", input.issue_ref)))?; + self.policies.clear_policy(&requester); + session.shutdown().await.map_err(AppError::from)?; + self.events.publish(DomainEvent::TicketAssistantClosed { + issue_ref: input.issue_ref, + }); + Ok(()) + } +} + +async fn find_profile( + profiles: &Arc, + profile_id: ProfileId, +) -> Result { + profiles + .list() + .await? + .into_iter() + .find(|profile| profile.id == profile_id) + .ok_or_else(|| AppError::NotFound(format!("profile {profile_id}"))) +} + +fn ticket_assistant_sandbox(project_root: &ProjectPath, issue_ref: IssueRef) -> SandboxPlan { + let run_dir = format!( + "{}/.ideai/run/ticket-assistant-{}", + project_root.as_str().trim_end_matches(['/', '\\']), + issue_ref.number().get() + ); + SandboxPlan::project_read_only(project_root.as_str().to_owned(), run_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_uses_project_read_only_preset() { + let root = ProjectPath::new("/tmp/project").unwrap(); + let issue_ref = IssueRef::from(domain::IssueNumber::new(7).unwrap()); + let plan = ticket_assistant_sandbox(&root, issue_ref); + assert_eq!(plan.allowed[0].abs_root, "/tmp/project"); + assert_eq!(plan.default_posture, domain::permission::Posture::Deny); + } +} diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs new file mode 100644 index 0000000..45a11b6 --- /dev/null +++ b/crates/application/tests/ticket_assistant.rs @@ -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 { + if self.issue.reference() == issue_ref { + Ok(self.issue.clone()) + } else { + Err(IssueStoreError::NotFound) + } + } + + async fn list( + &self, + _root: &ProjectPath, + _filter: IssueListFilter, + ) -> Result, 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 { + unimplemented!() + } + + async fn write_carnet( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _carnet: MarkdownDoc, + _actor: IssueActor, + _now_ms: u64, + _expected_version: IssueVersion, + ) -> Result { + unimplemented!() + } +} + +struct FakeProfiles { + profile: AgentProfile, +} + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, 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 { + Ok(true) + } + + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeAssistantContext { + prepared: Mutex>, +} + +#[async_trait] +impl AssistantContextProvider for FakeAssistantContext { + async fn prepare_ticket_assistant_context( + &self, + project: &Project, + issue: &Issue, + ) -> Result { + 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>, +} + +#[async_trait] +impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + self.id + } + + fn conversation_id(&self) -> Option { + None + } + + async fn send(&self, _prompt: &str) -> Result { + Ok(Box::new(std::iter::empty())) + } + + async fn shutdown(&self) -> Result<(), AgentSessionError> { + *self.shutdowns.lock().unwrap() += 1; + Ok(()) + } +} + +#[derive(Default)] +struct FakeFactory { + starts: Mutex)>>, + shutdowns: Arc>, +} + +#[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, 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>); + +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 { + self.0.lock().unwrap().get(requester).cloned() + } + + fn clear_policy(&self, requester: &str) { + self.0.lock().unwrap().remove(requester); + } +} + +#[derive(Default)] +struct SpyBus(Mutex>); + +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() + )); +} diff --git a/crates/domain/src/agent_tool_policy.rs b/crates/domain/src/agent_tool_policy.rs new file mode 100644 index 0000000..41bbbb3 --- /dev/null +++ b/crates/domain/src/agent_tool_policy.rs @@ -0,0 +1,113 @@ +//! MCP tool policy for constrained assistant sessions. +//! +//! This is a pure domain value. It models what a requester may do through the +//! MCP surface; it does not know how the MCP server enforces it. + +use serde::{Deserialize, Serialize}; + +use crate::issue::IssueRef; + +/// Tool policy attached to one agent/requester. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentToolPolicy { + /// Explicitly allowed MCP tool names. + pub allow: Vec, + /// Optional issue boundary for ticket mutations. + pub bound_issue: Option, + /// Whether tools outside [`Self::allow`] are denied. + pub deny_others: bool, +} + +impl AgentToolPolicy { + /// Builds a tool policy. + #[must_use] + pub fn new(allow: Vec, bound_issue: Option, deny_others: bool) -> Self { + Self { + allow, + bound_issue, + deny_others, + } + } + + /// Returns true when `tool` is allowed by the allowlist/default rule. + #[must_use] + pub fn permits(&self, tool: &str) -> bool { + self.allow.iter().any(|allowed| allowed == tool) || !self.deny_others + } + + /// Returns true when `tool` may mutate the supplied ticket reference. + /// + /// The method first applies the tool allowlist. Then, for known ticket mutation + /// tools, an optional [`Self::bound_issue`] fences the mutation to that single + /// issue reference. + #[must_use] + pub fn permits_ticket_mutation(&self, tool: &str, issue_ref: IssueRef) -> bool { + if !self.permits(tool) { + return false; + } + if !is_ticket_mutation_tool(tool) { + return true; + } + self.bound_issue.map_or(true, |bound| bound == issue_ref) + } +} + +fn is_ticket_mutation_tool(tool: &str) -> bool { + matches!( + tool, + "idea_ticket_update" + | "idea_ticket_update_status" + | "idea_ticket_update_priority" + | "idea_ticket_update_carnet" + | "idea_ticket_link" + | "idea_ticket_unlink" + | "idea_ticket_create" + ) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + fn issue_ref(raw: &str) -> IssueRef { + IssueRef::from_str(raw).unwrap() + } + + #[test] + fn permits_allowlisted_tool_when_deny_others_is_true() { + let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, true); + assert!(policy.permits("idea_ticket_read")); + assert!(!policy.permits("idea_ask_agent")); + } + + #[test] + fn deny_others_false_allows_non_listed_tools() { + let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, false); + assert!(policy.permits("idea_ticket_read")); + assert!(policy.permits("idea_ask_agent")); + } + + #[test] + fn ticket_mutation_is_bound_to_the_configured_issue() { + let policy = AgentToolPolicy::new( + vec!["idea_ticket_update".to_owned()], + Some(issue_ref("#7")), + true, + ); + assert!(policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7"))); + assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8"))); + } + + #[test] + fn ticket_mutation_requires_the_tool_itself_to_be_allowed() { + let policy = AgentToolPolicy::new( + vec!["idea_ticket_read".to_owned()], + Some(issue_ref("#7")), + true, + ); + assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7"))); + } +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 70186e3..d3e39cf 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -183,6 +183,18 @@ pub enum DomainEvent { /// Exit code. code: i32, }, + /// A ticket assistant session was opened for an issue. + TicketAssistantOpened { + /// Bound issue. + issue_ref: IssueRef, + /// Runtime profile used by the assistant. + profile_id: ProfileId, + }, + /// A ticket assistant session was closed for an issue. + TicketAssistantClosed { + /// Bound issue. + issue_ref: IssueRef, + }, /// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on /// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an /// explicit signal). Discrete, low-frequency beacon relayed to the front so the @@ -531,6 +543,14 @@ mod tests { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } + fn profile(n: u128) -> ProfileId { + ProfileId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn issue_ref(n: u64) -> IssueRef { + IssueRef::from(crate::IssueNumber::new(n).unwrap()) + } + // -- §21 : constructibilité + égalité PartialEq des 5 variantes -------------- #[test] @@ -638,6 +658,47 @@ mod tests { ); } + #[test] + fn ticket_assistant_opened_constructs_and_compares() { + let ev = DomainEvent::TicketAssistantOpened { + issue_ref: issue_ref(7), + profile_id: profile(9), + }; + assert_eq!( + ev, + DomainEvent::TicketAssistantOpened { + issue_ref: issue_ref(7), + profile_id: profile(9), + } + ); + assert_ne!( + ev, + DomainEvent::TicketAssistantOpened { + issue_ref: issue_ref(8), + profile_id: profile(9), + } + ); + } + + #[test] + fn ticket_assistant_closed_constructs_and_compares() { + let ev = DomainEvent::TicketAssistantClosed { + issue_ref: issue_ref(7), + }; + assert_eq!( + ev, + DomainEvent::TicketAssistantClosed { + issue_ref: issue_ref(7), + } + ); + assert_ne!( + ev, + DomainEvent::TicketAssistantClosed { + issue_ref: issue_ref(8), + } + ); + } + #[test] fn distinct_session_limit_variants_are_not_equal() { // Les variantes ne se confondent pas entre elles malgré des champs proches. diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index da988ea..45d096e 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -31,6 +31,7 @@ #![warn(missing_docs)] pub mod agent; +pub mod agent_tool_policy; pub mod background_task; pub mod conversation; pub mod conversation_log; @@ -79,6 +80,8 @@ pub use project::{Project, ProjectPath}; pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; +pub use agent_tool_policy::AgentToolPolicy; + pub use background_task::{ BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS, @@ -180,7 +183,8 @@ pub use orchestrator::{ }; pub use ports::{ - AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskCompletion, + AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError, + AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 4a9b9e4..d1b771c 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -28,6 +28,7 @@ use async_trait::async_trait; use thiserror::Error; use crate::agent::AgentManifest; +use crate::agent_tool_policy::AgentToolPolicy; use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; @@ -123,6 +124,43 @@ pub struct PreparedContext { pub project_root: String, } +/// Errors returned while preparing the IdeA-owned ticket assistant context. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AssistantContextError { + /// The context source was missing and no embedded default could be used. + #[error("assistant context not found")] + NotFound, + /// The context could not be read or rendered. + #[error("assistant context store failed: {0}")] + Store(String), +} + +/// Provides the IdeA-owned context used by ticket assistant sessions. +#[async_trait] +pub trait AssistantContextProvider: Send + Sync { + /// Renders a prepared context for a ticket assistant bound to `issue`. + /// + /// # Errors + /// [`AssistantContextError`] when the context cannot be read or rendered. + async fn prepare_ticket_assistant_context( + &self, + project: &Project, + issue: &Issue, + ) -> Result; +} + +/// Stores requester-scoped MCP tool policies for ephemeral assistant sessions. +pub trait AgentToolPolicyStore: Send + Sync { + /// Sets or replaces the policy for `requester`. + fn set_policy(&self, requester: String, policy: AgentToolPolicy); + + /// Returns the policy for `requester`, if any. + fn get_policy(&self, requester: &str) -> Option; + + /// Clears the policy for `requester`. + fn clear_policy(&self, requester: &str); +} + /// Enriched, **best-effort** details about a conversation, specific to a CLI's /// on-disk transcript format (CONTEXT §T6). /// diff --git a/crates/domain/src/sandbox.rs b/crates/domain/src/sandbox.rs index 13eaabc..c054c16 100644 --- a/crates/domain/src/sandbox.rs +++ b/crates/domain/src/sandbox.rs @@ -145,6 +145,31 @@ pub struct SandboxPlan { pub default_posture: Posture, } +impl SandboxPlan { + /// Builds the ticket-assistant preset: read-only access to the project root, + /// denied writes by default, while keeping the assistant run directory + /// writable so CLI session metadata/resume files can still function. + #[must_use] + pub fn project_read_only(project_root: impl Into, run_dir: impl Into) -> Self { + let project_root = project_root.into(); + let run_dir = run_dir.into(); + let mut allowed = vec![PathGrant { + abs_root: project_root, + access: PathAccess::RO, + }]; + if !run_dir.trim().is_empty() { + allowed.push(PathGrant { + abs_root: run_dir, + access: PathAccess::RO | PathAccess::RW, + }); + } + Self { + allowed, + default_posture: Posture::Deny, + } + } +} + /// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots /// it emits. Borrowed: a plan is computed at the launch site, never stored. pub struct SandboxContext<'a> { @@ -465,6 +490,19 @@ mod tests { assert!(!g.access.contains(PathAccess::RW)); } + #[test] + fn project_read_only_preset_allows_project_read_and_run_dir_write_only() { + let plan = SandboxPlan::project_read_only(ROOT, "/home/anthony/.ideai/run/assistant"); + let project = grant(&plan, ROOT).expect("project root granted"); + assert_eq!(project.access, PathAccess::RO); + assert!(!project.access.contains(PathAccess::RW)); + + let run = grant(&plan, "/home/anthony/.ideai/run/assistant").expect("run dir granted"); + assert!(run.access.contains(PathAccess::RO)); + assert!(run.access.contains(PathAccess::RW)); + assert_eq!(plan.default_posture, Posture::Deny); + } + #[test] fn write_and_delete_map_to_rw() { let e = eff( diff --git a/crates/infrastructure/src/assistant/default_ticket_assistant.md b/crates/infrastructure/src/assistant/default_ticket_assistant.md new file mode 100644 index 0000000..b3e9e26 --- /dev/null +++ b/crates/infrastructure/src/assistant/default_ticket_assistant.md @@ -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. diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs new file mode 100644 index 0000000..ce6861c --- /dev/null +++ b/crates/infrastructure/src/assistant/mod.rs @@ -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, + app_data_dir: String, +} + +impl FsAssistantContextStore { + /// Builds the store from an injected filesystem and app-data directory. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> 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 { + 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 { + 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(), + }) + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 25d7ba5..1da72b2 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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, diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index 4ca0916..212bf05 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -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}; diff --git a/crates/infrastructure/src/orchestrator/mcp/policy.rs b/crates/infrastructure/src/orchestrator/mcp/policy.rs new file mode 100644 index 0000000..77baa83 --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/policy.rs @@ -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>, +} + +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, 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 { + 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 { + 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::().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); + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index e24212b..a0ee572 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -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>, + /// Optional per-requester MCP tool policy registry for constrained sessions. + tool_policies: Option>, } 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) -> 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 = 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 diff --git a/crates/infrastructure/tests/assistant_context_store.rs b/crates/infrastructure/tests/assistant_context_store.rs new file mode 100644 index 0000000..abd96c1 --- /dev/null +++ b/crates/infrastructure/tests/assistant_context_store.rs @@ -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 = 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")); +} diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index cf4489c..7f1c290 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -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 // --------------------------------------------------------------------------- diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 7caa049..e65332e 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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(); private sprints = new Map>(); private sprintCounters = new Map(); + /** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */ + private chatSessions = new Map(); + 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 { + // 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 { + if (this.chatSessions.delete(issueRef)) { + this.system?.emit({ type: "ticketAssistantClosed", issueRef }); + } + } + + async sendTicketChat( + sessionId: string, + message: string, + onChunk: (chunk: ReplyChunk) => void, + ): Promise { + 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) { diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index ff8cf3f..a3b7336 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -10,13 +10,15 @@ * {@link isTicketVersionConflict} to branch on it in the UI. */ -import { invoke } from "@tauri-apps/api/core"; +import { Channel, invoke } from "@tauri-apps/api/core"; import type { GatewayError, + ReplyChunk, Sprint, Ticket, TicketCarnet, + TicketChat, TicketLinkKind, TicketList, } from "@/domain"; @@ -182,4 +184,38 @@ export class TauriTicketGateway implements TicketGateway { request: { projectId, sprintId }, }); } + + async openTicketChat( + projectId: string, + issueRef: string, + profileId: string, + ): Promise { + return invoke("open_ticket_chat", { + request: { projectId, issueRef, profileId }, + }); + } + + async closeTicketChat(projectId: string, issueRef: string): Promise { + await invoke("close_ticket_chat", { + request: { projectId, issueRef }, + }); + } + + async sendTicketChat( + sessionId: string, + message: string, + onChunk: (chunk: ReplyChunk) => void, + ): Promise { + // Per-turn reply channel, tagged on `kind` (`textDelta`|`toolActivity`| + // `final`). Reuses the shared `agent_send` chat transport (the same command + // and ChatBridge the structured chat cell uses). Wire the handler before the + // invoke so no early chunk is missed. + const channel = new Channel(); + channel.onmessage = (chunk) => onChunk(chunk); + await invoke("agent_send", { + sessionId, + prompt: message, + onReply: channel, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 54042b3..960e8bb 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -187,6 +187,21 @@ export type DomainEvent = version: number; } | { type: "sprintDeleted"; sprintId: string } + | { + /** + * A ticket AI assistant chat was opened (ticket #8). Mirror of the backend + * `DomainEventDto::TicketAssistantOpened`. `issueRef` is the bound ticket, + * `profileId` the runtime AI profile driving the assistant. + */ + type: "ticketAssistantOpened"; + issueRef: string; + profileId: string; + } + | { + /** A ticket AI assistant chat was closed/disposed (ticket #8). */ + type: "ticketAssistantClosed"; + issueRef: string; + } | { /** * An intermediate assistant announcement emitted **during** an inter-agent @@ -963,3 +978,25 @@ export interface TicketCarnet { carnet: string; version: number; } + +/** + * A ticket AI assistant chat session (mirror of the backend `TicketChatDto`, + * ticket #8). `sessionId` is the live structured session driving the assistant; + * `requester` is the assistant agent id; `issueRef` the bound ticket. + */ +export interface TicketChat { + sessionId: string; + requester: string; + issueRef: TicketRef; +} + +/** + * One streamed chunk of an assistant turn (mirror of the backend `ReplyChunk`, + * tagged on `kind`). `textDelta` is an incremental text fragment; `toolActivity` + * a best-effort activity badge; `final` the deterministic end-of-turn chunk + * carrying the aggregated content — after it the turn is frozen. + */ +export type ReplyChunk = + | { kind: "textDelta"; text: string } + | { kind: "toolActivity"; label: string } + | { kind: "final"; content: string }; diff --git a/frontend/src/features/tickets/TicketAssistantPanel.tsx b/frontend/src/features/tickets/TicketAssistantPanel.tsx new file mode 100644 index 0000000..a6d9016 --- /dev/null +++ b/frontend/src/features/tickets/TicketAssistantPanel.tsx @@ -0,0 +1,178 @@ +/** + * Ticket AI assistant chat panel (ticket #8, F2). + * + * Consumes {@link useTicketAssistant} to drive a ticket-scoped assistant: pick + * an AI profile, open a read-only session bound to this ticket, then converse. + * The assistant can only read files and edit *this* ticket — its edits refresh + * the ticket automatically (see {@link useTicketDetail}). Purely presentational: + * all session/streaming logic lives in the hook. + */ + +import { useEffect, useRef, useState } from "react"; + +import { Button, Spinner, cn } from "@/shared"; +import { useTicketAssistant } from "./useTicketAssistant"; + +const selectClass = cn( + "h-9 rounded-md bg-raised px-3 text-sm text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +export interface TicketAssistantPanelProps { + projectId: string; + ticketRef: string; +} + +export function TicketAssistantPanel({ + projectId, + ticketRef, +}: TicketAssistantPanelProps) { + const vm = useTicketAssistant(projectId, ticketRef); + const [profileId, setProfileId] = useState(""); + const [draft, setDraft] = useState(""); + + // Auto-scroll the transcript to the newest content as it streams in. + const scrollRef = useRef(null); + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [vm.turns]); + + const open = vm.sessionId !== null; + + function submit() { + const text = draft.trim(); + if (!text || vm.busy) return; + void vm.send(text); + setDraft(""); + } + + return ( +
+ {!open ? ( + // ── Session opener ── +
+ + +
+ ) : ( + // ── Live conversation ── +
+
+ + Conversation ouverte — l'assistant peut lire le projet et éditer ce + ticket. + + +
+ +
+ {vm.turns.length === 0 ? ( +

+ Démarrez la conversation en décrivant ce que l'assistant doit + faire sur ce ticket. +

+ ) : ( + vm.turns.map((turn, i) => ( +
+
+ {turn.text} + {turn.pending && ( + + + + )} +
+
+ )) + )} +
+ +
+