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:
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
222
crates/application/src/ticket_assistant.rs
Normal file
222
crates/application/src/ticket_assistant.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
339
crates/application/tests/ticket_assistant.rs
Normal file
339
crates/application/tests/ticket_assistant.rs
Normal 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()
|
||||
));
|
||||
}
|
||||
113
crates/domain/src/agent_tool_policy.rs
Normal file
113
crates/domain/src/agent_tool_policy.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
///
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
76
crates/infrastructure/src/assistant/mod.rs
Normal file
76
crates/infrastructure/src/assistant/mod.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
76
crates/infrastructure/src/orchestrator/mcp/policy.rs
Normal file
76
crates/infrastructure/src/orchestrator/mcp/policy.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
110
crates/infrastructure/tests/assistant_context_store.rs
Normal file
110
crates/infrastructure/tests/assistant_context_store.rs
Normal 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"));
|
||||
}
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user