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

@ -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");
}
}

View File

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

View File

@ -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<UnassignTicketFromSprint>,
/// MCP provider for the public `idea_ticket_*` tools.
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
/// Open an ephemeral AI assistant chat bound to one ticket.
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
/// Close an ephemeral AI assistant chat bound to one ticket.
pub close_ticket_assistant: Arc<CloseTicketAssistant>,
/// Shared MCP tool policy registry used by ticket assistant sessions.
pub tool_policy_registry: Arc<ToolPolicyRegistry>,
/// Generic PTY↔Channel bridge registry (consumed by L3).
pub pty_bridge: Arc<PtyBridge>,
/// 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<dyn AgentToolPolicyStore>;
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
)) as Arc<dyn AssistantContextProvider>;
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,

View File

@ -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<SprintDto>,
}
#[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<TicketChatDto, ErrorDto> {
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<AgentId, ErrorDto> {
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
}
fn parse_profile_id_dto(raw: &str) -> Result<ProfileId, ErrorDto> {
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<SprintId, ErrorDto> {
Uuid::parse_str(raw)
.map(SprintId::from_uuid)

View File

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

View File

@ -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<dyn AgentSession>,
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<HashMap<SessionId, StructuredEntry>>,
ticket_assistants: Mutex<HashMap<IssueRef, TicketAssistantEntry>>,
}
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<dyn AgentSession>,
) -> Option<Arc<dyn AgentSession>> {
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<Arc<dyn AgentSession>> {
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<String> {
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<dyn AgentSession>, 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<Arc<dyn AgentSession>> {
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::<Vec<_>>()
})
.unwrap_or_default()
.into_iter()
.chain(
self.ticket_assistants
.lock()
.map(|m| {
m.values()
.map(|e| Arc::clone(&e.session))
.collect::<Vec<_>>()
})
.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.

View File

@ -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<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
}
/// 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<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>,
factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
) -> 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<OpenTicketAssistantOutput, AppError> {
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<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
}
/// 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<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
) -> 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<dyn ProfileStore>,
profile_id: ProfileId,
) -> Result<AgentProfile, AppError> {
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);
}
}

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()
));
}

View File

@ -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<String>,
/// Optional issue boundary for ticket mutations.
pub bound_issue: Option<IssueRef>,
/// Whether tools outside [`Self::allow`] are denied.
pub deny_others: bool,
}
impl AgentToolPolicy {
/// Builds a tool policy.
#[must_use]
pub fn new(allow: Vec<String>, bound_issue: Option<IssueRef>, 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")));
}
}

View File

@ -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.

View File

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

View File

@ -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<PreparedContext, AssistantContextError>;
}
/// 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<AgentToolPolicy>;
/// 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).
///

View File

@ -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<String>, run_dir: impl Into<String>) -> 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(

View File

@ -0,0 +1,5 @@
# Ticket Assistant
You are IdeA's ticket editing assistant. Help improve the bound ticket only.
Read the project context when needed, propose concise changes, and use ticket
tools only for the ticket explicitly injected below.

View File

@ -0,0 +1,76 @@
//! Ticket assistant infrastructure adapters.
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc,
PreparedContext, Project, RemotePath,
};
const ASSISTANT_DIR: &str = "assistant";
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");
/// File-backed provider for the IdeA-owned ticket assistant context.
#[derive(Clone)]
pub struct FsAssistantContextStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsAssistantContextStore {
/// Builds the store from an injected filesystem and app-data directory.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
fn join(&self, rel: &str) -> String {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}
fn context_path(&self) -> RemotePath {
RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}")))
}
async fn read_base_context(&self) -> Result<String, AssistantContextError> {
let path = self.context_path();
match self.fs.read(&path).await {
Ok(bytes) => {
String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string()))
}
Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()),
Err(e) => Err(AssistantContextError::Store(e.to_string())),
}
}
}
#[async_trait]
impl AssistantContextProvider for FsAssistantContextStore {
async fn prepare_ticket_assistant_context(
&self,
project: &Project,
issue: &Issue,
) -> Result<PreparedContext, AssistantContextError> {
let mut content = self.read_base_context().await?;
content.push_str("\n\n## Bound Ticket\n\n");
content.push_str(&format!("- Ref: {}\n", issue.reference()));
content.push_str(&format!("- Title: {}\n", issue.title));
content.push_str("\n### Description\n\n");
content.push_str(issue.description.as_str());
content.push_str("\n\n### Carnet\n\n");
content.push_str(issue.carnet.as_str());
content.push('\n');
Ok(PreparedContext {
content: MarkdownDoc::new(content),
relative_path: TICKET_ASSISTANT_FILE.to_owned(),
project_root: project.root.as_str().to_owned(),
})
}
}

View File

@ -12,6 +12,7 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod assistant;
pub mod background_task;
pub mod clock;
pub mod conversation;
@ -39,6 +40,7 @@ pub mod sprints;
pub mod store;
pub mod timeparse;
pub use assistant::FsAssistantContextStore;
pub use background_task::{
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
@ -64,6 +66,7 @@ pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
ToolPolicyRegistry,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -29,6 +29,7 @@
//! child process).
pub mod jsonrpc;
pub mod policy;
pub mod server;
pub mod tickets;
pub mod tools;
@ -37,6 +38,7 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use policy::ToolPolicyRegistry;
pub use server::McpServer;
pub use tickets::{TicketToolError, TicketToolProvider};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};

View File

@ -0,0 +1,76 @@
//! In-memory MCP tool policy registry.
use std::collections::HashMap;
use std::sync::RwLock;
use domain::AgentToolPolicy;
use domain::AgentToolPolicyStore;
/// Stores per-requester MCP tool policies for live assistant sessions.
#[derive(Default)]
pub struct ToolPolicyRegistry {
policies: RwLock<HashMap<String, AgentToolPolicy>>,
}
impl ToolPolicyRegistry {
/// Builds an empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets or replaces the policy for `requester`.
pub fn set(&self, requester: impl Into<String>, policy: AgentToolPolicy) {
self.policies
.write()
.unwrap()
.insert(requester.into(), policy);
}
/// Returns the policy for `requester`, if any.
#[must_use]
pub fn get(&self, requester: &str) -> Option<AgentToolPolicy> {
self.policies.read().unwrap().get(requester).cloned()
}
/// Clears the policy for `requester`.
pub fn clear(&self, requester: &str) {
self.policies.write().unwrap().remove(requester);
}
}
impl AgentToolPolicyStore for ToolPolicyRegistry {
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
self.set(requester, policy);
}
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
self.get(requester)
}
fn clear_policy(&self, requester: &str) {
self.clear(requester);
}
}
#[cfg(test)]
mod tests {
use domain::IssueRef;
use super::*;
#[test]
fn set_get_clear_roundtrips_by_requester() {
let registry = ToolPolicyRegistry::new();
let policy = AgentToolPolicy::new(
vec!["idea_ticket_read".to_owned()],
Some("#7".parse::<IssueRef>().unwrap()),
true,
);
registry.set("assistant-1", policy.clone());
assert_eq!(registry.get("assistant-1"), Some(policy));
assert_eq!(registry.get("assistant-2"), None);
registry.clear("assistant-1");
assert_eq!(registry.get("assistant-1"), None);
}
}

View File

@ -16,11 +16,12 @@
//! at the composition root (M3), exactly like the watcher — no application logic is
//! duplicated here.
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use application::OrchestratorService;
use domain::{DomainEvent, OrchestrationSource, Project};
use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project};
use serde_json::{json, Value};
use tokio::sync::mpsc;
@ -28,6 +29,7 @@ use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::policy::ToolPolicyRegistry;
use super::tickets::TicketToolProvider;
use super::tools::{self, ToolMapError};
@ -65,6 +67,8 @@ pub struct McpServer {
/// Optional public ticket provider. The MCP surface says `ticket`; the
/// provider maps those calls to application/domain `Issue` use cases.
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
}
impl McpServer {
@ -80,6 +84,7 @@ impl McpServer {
requester: String::new(),
ready_sink: None,
ticket_tools: None,
tool_policies: None,
}
}
@ -111,6 +116,13 @@ impl McpServer {
self
}
/// Attaches the requester-scoped MCP tool policy registry.
#[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
self.tool_policies = Some(tool_policies);
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
@ -128,6 +140,7 @@ impl McpServer {
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
tool_policies: self.tool_policies.clone(),
}
}
@ -300,8 +313,17 @@ impl McpServer {
/// The `tools/list` result: the catalogue as MCP tool descriptors.
fn tools_list_result(&self) -> Value {
let policy = self
.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester));
let tools: Vec<Value> = tools::catalogue()
.into_iter()
.filter(|t| {
policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
})
.map(|t| {
json!({
"name": t.name,
@ -324,6 +346,14 @@ impl McpServer {
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
if let Some(policy) = self
.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester))
{
self.enforce_tool_policy(&policy, &name, &arguments)?;
}
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
let started = Instant::now();
@ -437,6 +467,47 @@ impl McpServer {
}
}
fn enforce_tool_policy(
&self,
policy: &AgentToolPolicy,
name: &str,
arguments: &Value,
) -> Result<(), JsonRpcError> {
if !policy.permits(name) {
return Err(JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!(
"tool `{name}` is not permitted for requester {}",
self.requester
),
));
}
if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") {
let raw_ref = arguments
.get("ref")
.and_then(Value::as_str)
.ok_or_else(|| {
JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("tool `{name}` requires a ticket ref under the active policy"),
)
})?;
let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| {
JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("invalid ticket ref: {e}"),
)
})?;
if !policy.permits_ticket_mutation(name, issue_ref) {
return Err(JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("tool `{name}` is not permitted for ticket {issue_ref}"),
));
}
}
Ok(())
}
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5

View File

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

View File

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