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.
|
/// Exit code.
|
||||||
code: i32,
|
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
|
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
|
||||||
/// "Envoyer" while `busy` is `true`.
|
/// "Envoyer" while `busy` is `true`.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -551,6 +565,16 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
code: *code,
|
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 {
|
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
busy: *busy,
|
busy: *busy,
|
||||||
@ -1081,4 +1105,24 @@ mod tests {
|
|||||||
assert_eq!(json["to"], to.to_string());
|
assert_eq!(json["to"], to.to_string());
|
||||||
assert_eq!(json["version"], 3);
|
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,
|
commands::list_agents,
|
||||||
tickets::ticket_create,
|
tickets::ticket_create,
|
||||||
tickets::ticket_read,
|
tickets::ticket_read,
|
||||||
|
tickets::open_ticket_chat,
|
||||||
|
tickets::close_ticket_chat,
|
||||||
tickets::ticket_list,
|
tickets::ticket_list,
|
||||||
tickets::ticket_update,
|
tickets::ticket_update,
|
||||||
tickets::ticket_read_carnet,
|
tickets::ticket_read_carnet,
|
||||||
|
|||||||
@ -15,39 +15,40 @@ use application::{
|
|||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
||||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
||||||
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||||
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||||
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile,
|
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
|
||||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines,
|
||||||
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory,
|
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean,
|
||||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
||||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
ListAgentsInput, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles,
|
||||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
|
||||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||||
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||||
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
|
||||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
|
||||||
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, Embedder,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
|
BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
|
||||||
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
|
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
|
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
||||||
SprintStore, TemplateStore, WakeError, WakeReason,
|
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
||||||
|
WakeError, WakeReason,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -65,16 +66,16 @@ use infrastructure::{
|
|||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
|
||||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
||||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||||
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::chat::ChatBridge;
|
use crate::chat::ChatBridge;
|
||||||
@ -822,6 +823,12 @@ pub struct AppState {
|
|||||||
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
||||||
/// MCP provider for the public `idea_ticket_*` tools.
|
/// MCP provider for the public `idea_ticket_*` tools.
|
||||||
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
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).
|
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||||
pub pty_bridge: Arc<PtyBridge>,
|
pub pty_bridge: Arc<PtyBridge>,
|
||||||
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
|
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
|
||||||
@ -1279,6 +1286,26 @@ impl AppState {
|
|||||||
unlink: Arc::clone(&unlink_issues),
|
unlink: Arc::clone(&unlink_issues),
|
||||||
list_sprints: Arc::clone(&list_sprints),
|
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) ---
|
// --- Project permissions (LP1) ---
|
||||||
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
||||||
@ -2178,6 +2205,9 @@ impl AppState {
|
|||||||
assign_ticket_to_sprint,
|
assign_ticket_to_sprint,
|
||||||
unassign_ticket_from_sprint,
|
unassign_ticket_from_sprint,
|
||||||
ticket_tool_provider,
|
ticket_tool_provider,
|
||||||
|
open_ticket_assistant,
|
||||||
|
close_ticket_assistant,
|
||||||
|
tool_policy_registry,
|
||||||
pty_bridge,
|
pty_bridge,
|
||||||
structured_sessions,
|
structured_sessions,
|
||||||
chat_bridge,
|
chat_bridge,
|
||||||
@ -2337,7 +2367,8 @@ impl AppState {
|
|||||||
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
|
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
|
||||||
.with_events(events)
|
.with_events(events)
|
||||||
.with_ready_sink(ready_sink)
|
.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,
|
endpoint,
|
||||||
listener,
|
listener,
|
||||||
project_id,
|
project_id,
|
||||||
|
|||||||
@ -14,15 +14,16 @@ use tauri::State;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput,
|
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
|
||||||
CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput,
|
CreateIssueInput, CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput,
|
||||||
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
|
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput,
|
||||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
|
||||||
|
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
|
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
|
||||||
ProjectId, SprintId, SprintStatus, SprintVersion,
|
Project, ProjectId, SprintId, SprintStatus, SprintVersion,
|
||||||
};
|
};
|
||||||
use infrastructure::{TicketToolError, TicketToolProvider};
|
use infrastructure::{TicketToolError, TicketToolProvider};
|
||||||
|
|
||||||
@ -106,6 +107,29 @@ pub struct SprintListDto {
|
|||||||
pub items: Vec<SprintDto>,
|
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.
|
/// Public link DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -588,6 +612,43 @@ pub async fn ticket_read(
|
|||||||
Ok(TicketDto::from_issue(issue, carnet))
|
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]
|
#[tauri::command]
|
||||||
pub async fn ticket_list(
|
pub async fn ticket_list(
|
||||||
request: TicketListRequestDto,
|
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}")))
|
.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> {
|
fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
|
||||||
Uuid::parse_str(raw)
|
Uuid::parse_str(raw)
|
||||||
.map(SprintId::from_uuid)
|
.map(SprintId::from_uuid)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ pub mod skill;
|
|||||||
pub mod sprints;
|
pub mod sprints;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
pub mod ticket_assistant;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
pub mod workstate;
|
pub mod workstate;
|
||||||
|
|
||||||
@ -149,6 +150,10 @@ pub use terminal::{
|
|||||||
ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
|
ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
|
||||||
WriteToTerminalInput,
|
WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
|
pub use ticket_assistant::{
|
||||||
|
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
|
||||||
|
OpenTicketAssistantOutput,
|
||||||
|
};
|
||||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||||
pub use workstate::{
|
pub use workstate::{
|
||||||
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
|
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use domain::conversation::ConversationId;
|
use domain::conversation::ConversationId;
|
||||||
use domain::ports::{AgentSession, PtyHandle};
|
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.
|
/// Runtime family of a live agent session.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@ -303,6 +303,12 @@ struct StructuredEntry {
|
|||||||
node_id: NodeId,
|
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).
|
/// 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
|
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
|
||||||
@ -318,6 +324,7 @@ struct StructuredEntry {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct StructuredSessions {
|
pub struct StructuredSessions {
|
||||||
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
|
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
|
||||||
|
ticket_assistants: Mutex<HashMap<IssueRef, TicketAssistantEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveAgentRegistry for StructuredSessions {
|
impl LiveAgentRegistry for StructuredSessions {
|
||||||
@ -339,6 +346,7 @@ impl StructuredSessions {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: Mutex::new(HashMap::new()),
|
entries: Mutex::new(HashMap::new()),
|
||||||
|
ticket_assistants: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,6 +373,60 @@ impl StructuredSessions {
|
|||||||
.lock()
|
.lock()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
|
.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.
|
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
|
||||||
@ -460,6 +522,15 @@ impl StructuredSessions {
|
|||||||
.lock()
|
.lock()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|mut m| m.remove(id).map(|e| e.session))
|
.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
|
/// 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>> {
|
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
|
||||||
self.entries
|
self.entries
|
||||||
.lock()
|
.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()
|
.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.
|
/// Nombre de sessions structurées vivantes.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
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.
|
/// 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.
|
/// Exit code.
|
||||||
code: i32,
|
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
|
/// 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
|
/// 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
|
/// 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))
|
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 --------------
|
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn distinct_session_limit_variants_are_not_equal() {
|
fn distinct_session_limit_variants_are_not_equal() {
|
||||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod agent_tool_policy;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
@ -79,6 +80,8 @@ pub use project::{Project, ProjectPath};
|
|||||||
|
|
||||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||||
|
|
||||||
|
pub use agent_tool_policy::AgentToolPolicy;
|
||||||
|
|
||||||
pub use background_task::{
|
pub use background_task::{
|
||||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
||||||
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||||
@ -180,7 +183,8 @@ pub use orchestrator::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use ports::{
|
pub use ports::{
|
||||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskCompletion,
|
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
|
||||||
|
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||||
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||||
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||||
|
|||||||
@ -28,6 +28,7 @@ use async_trait::async_trait;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::agent::AgentManifest;
|
use crate::agent::AgentManifest;
|
||||||
|
use crate::agent_tool_policy::AgentToolPolicy;
|
||||||
use crate::background_task::{
|
use crate::background_task::{
|
||||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
};
|
};
|
||||||
@ -123,6 +124,43 @@ pub struct PreparedContext {
|
|||||||
pub project_root: String,
|
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
|
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
||||||
/// on-disk transcript format (CONTEXT §T6).
|
/// on-disk transcript format (CONTEXT §T6).
|
||||||
///
|
///
|
||||||
|
|||||||
@ -145,6 +145,31 @@ pub struct SandboxPlan {
|
|||||||
pub default_posture: Posture,
|
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
|
/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots
|
||||||
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
|
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
|
||||||
pub struct SandboxContext<'a> {
|
pub struct SandboxContext<'a> {
|
||||||
@ -465,6 +490,19 @@ mod tests {
|
|||||||
assert!(!g.access.contains(PathAccess::RW));
|
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]
|
#[test]
|
||||||
fn write_and_delete_map_to_rw() {
|
fn write_and_delete_map_to_rw() {
|
||||||
let e = eff(
|
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)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod assistant;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
pub mod clock;
|
pub mod clock;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
@ -39,6 +40,7 @@ pub mod sprints;
|
|||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod timeparse;
|
pub mod timeparse;
|
||||||
|
|
||||||
|
pub use assistant::FsAssistantContextStore;
|
||||||
pub use background_task::{
|
pub use background_task::{
|
||||||
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
|
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
|
||||||
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
|
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
|
||||||
@ -64,6 +66,7 @@ pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
|||||||
pub use mailbox::InMemoryMailbox;
|
pub use mailbox::InMemoryMailbox;
|
||||||
pub use orchestrator::mcp::{
|
pub use orchestrator::mcp::{
|
||||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||||
|
ToolPolicyRegistry,
|
||||||
};
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
//! child process).
|
//! child process).
|
||||||
|
|
||||||
pub mod jsonrpc;
|
pub mod jsonrpc;
|
||||||
|
pub mod policy;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
@ -37,6 +38,7 @@ pub mod transport;
|
|||||||
pub use jsonrpc::{
|
pub use jsonrpc::{
|
||||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||||
};
|
};
|
||||||
|
pub use policy::ToolPolicyRegistry;
|
||||||
pub use server::McpServer;
|
pub use server::McpServer;
|
||||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
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
|
//! at the composition root (M3), exactly like the watcher — no application logic is
|
||||||
//! duplicated here.
|
//! duplicated here.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use application::OrchestratorService;
|
use application::OrchestratorService;
|
||||||
use domain::{DomainEvent, OrchestrationSource, Project};
|
use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ use super::jsonrpc::{
|
|||||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||||
JSONRPC_VERSION,
|
JSONRPC_VERSION,
|
||||||
};
|
};
|
||||||
|
use super::policy::ToolPolicyRegistry;
|
||||||
use super::tickets::TicketToolProvider;
|
use super::tickets::TicketToolProvider;
|
||||||
use super::tools::{self, ToolMapError};
|
use super::tools::{self, ToolMapError};
|
||||||
|
|
||||||
@ -65,6 +67,8 @@ pub struct McpServer {
|
|||||||
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||||
/// provider maps those calls to application/domain `Issue` use cases.
|
/// provider maps those calls to application/domain `Issue` use cases.
|
||||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||||
|
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||||
|
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpServer {
|
impl McpServer {
|
||||||
@ -80,6 +84,7 @@ impl McpServer {
|
|||||||
requester: String::new(),
|
requester: String::new(),
|
||||||
ready_sink: None,
|
ready_sink: None,
|
||||||
ticket_tools: None,
|
ticket_tools: None,
|
||||||
|
tool_policies: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +116,13 @@ impl McpServer {
|
|||||||
self
|
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
|
/// 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).
|
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||||
///
|
///
|
||||||
@ -128,6 +140,7 @@ impl McpServer {
|
|||||||
requester: requester.into(),
|
requester: requester.into(),
|
||||||
ready_sink: self.ready_sink.clone(),
|
ready_sink: self.ready_sink.clone(),
|
||||||
ticket_tools: self.ticket_tools.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.
|
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
||||||
fn tools_list_result(&self) -> Value {
|
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()
|
let tools: Vec<Value> = tools::catalogue()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|t| {
|
||||||
|
policy
|
||||||
|
.as_ref()
|
||||||
|
.map_or(true, |policy| policy.permits(t.name))
|
||||||
|
})
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
json!({
|
json!({
|
||||||
"name": t.name,
|
"name": t.name,
|
||||||
@ -324,6 +346,14 @@ impl McpServer {
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
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
|
// 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.
|
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
|
||||||
let started = Instant::now();
|
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
|
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
|
||||||
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
||||||
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
|
/// 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).
|
//! `OrchestratorRequest::validate` (typed error, **no** dispatch).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -44,7 +45,7 @@ use domain::project::{Project, ProjectPath};
|
|||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::skill::{Skill, SkillScope};
|
use domain::skill::{Skill, SkillScope};
|
||||||
use domain::terminal::{SessionKind, TerminalSession};
|
use domain::terminal::{SessionKind, TerminalSession};
|
||||||
use domain::{PtySize, SessionId};
|
use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
@ -55,7 +56,7 @@ use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
|||||||
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||||
SystemMillisClock,
|
SystemMillisClock, ToolPolicyRegistry,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
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)
|
// 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));
|
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
|
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -35,12 +35,14 @@ import type {
|
|||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
|
ReplyChunk,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
Sprint,
|
Sprint,
|
||||||
Template,
|
Template,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
|
TicketChat,
|
||||||
TicketLink,
|
TicketLink,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
TicketList,
|
TicketList,
|
||||||
@ -1816,6 +1818,9 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
private counters = new Map<string, number>();
|
private counters = new Map<string, number>();
|
||||||
private sprints = new Map<string, Map<string, Sprint>>();
|
private sprints = new Map<string, Map<string, Sprint>>();
|
||||||
private sprintCounters = new Map<string, number>();
|
private sprintCounters = new Map<string, number>();
|
||||||
|
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
||||||
|
private chatSessions = new Map<string, string>();
|
||||||
|
private chatCounter = 0;
|
||||||
|
|
||||||
constructor(private readonly system?: MockSystemGateway) {}
|
constructor(private readonly system?: MockSystemGateway) {}
|
||||||
|
|
||||||
@ -2235,6 +2240,51 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
this.system?.emit({ type: "sprintDeleted", sprintId });
|
this.system?.emit({ type: "sprintDeleted", sprintId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openTicketChat(
|
||||||
|
projectId: string,
|
||||||
|
issueRef: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<TicketChat> {
|
||||||
|
// Ensure the ticket exists (mirrors the backend resolving the ref).
|
||||||
|
this.require(projectId, issueRef);
|
||||||
|
const sessionId = `mock-ticket-chat-${++this.chatCounter}`;
|
||||||
|
this.chatSessions.set(issueRef, sessionId);
|
||||||
|
this.system?.emit({ type: "ticketAssistantOpened", issueRef, profileId });
|
||||||
|
return { sessionId, requester: "ticket-assistant", issueRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTicketChat(_projectId: string, issueRef: string): Promise<void> {
|
||||||
|
if (this.chatSessions.delete(issueRef)) {
|
||||||
|
this.system?.emit({ type: "ticketAssistantClosed", issueRef });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTicketChat(
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (![...this.chatSessions.values()].includes(sessionId)) {
|
||||||
|
throw {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `chat session ${sessionId} not found`,
|
||||||
|
} as GatewayError;
|
||||||
|
}
|
||||||
|
// Simulate a streamed assistant turn: a couple of text deltas, then the
|
||||||
|
// deterministic `final` chunk. Delivered across microtasks (like a real
|
||||||
|
// Channel), so a consumer that awaits sees the whole turn.
|
||||||
|
const content = `Assistant: reçu « ${message} ».`;
|
||||||
|
const chunks: ReplyChunk[] = [
|
||||||
|
{ kind: "textDelta", text: "Assistant: reçu " },
|
||||||
|
{ kind: "textDelta", text: `« ${message} ».` },
|
||||||
|
{ kind: "final", content },
|
||||||
|
];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await Promise.resolve();
|
||||||
|
onChunk(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private requireSprint(projectId: string, sprintId: string): Sprint {
|
private requireSprint(projectId: string, sprintId: string): Sprint {
|
||||||
const sprint = this.projectSprints(projectId).get(sprintId);
|
const sprint = this.projectSprints(projectId).get(sprintId);
|
||||||
if (!sprint) {
|
if (!sprint) {
|
||||||
|
|||||||
@ -10,13 +10,15 @@
|
|||||||
* {@link isTicketVersionConflict} to branch on it in the UI.
|
* {@link isTicketVersionConflict} to branch on it in the UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
ReplyChunk,
|
||||||
Sprint,
|
Sprint,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
|
TicketChat,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
TicketList,
|
TicketList,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
@ -182,4 +184,38 @@ export class TauriTicketGateway implements TicketGateway {
|
|||||||
request: { projectId, sprintId },
|
request: { projectId, sprintId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openTicketChat(
|
||||||
|
projectId: string,
|
||||||
|
issueRef: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<TicketChat> {
|
||||||
|
return invoke<TicketChat>("open_ticket_chat", {
|
||||||
|
request: { projectId, issueRef, profileId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
|
||||||
|
await invoke<void>("close_ticket_chat", {
|
||||||
|
request: { projectId, issueRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTicketChat(
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
// Per-turn reply channel, tagged on `kind` (`textDelta`|`toolActivity`|
|
||||||
|
// `final`). Reuses the shared `agent_send` chat transport (the same command
|
||||||
|
// and ChatBridge the structured chat cell uses). Wire the handler before the
|
||||||
|
// invoke so no early chunk is missed.
|
||||||
|
const channel = new Channel<ReplyChunk>();
|
||||||
|
channel.onmessage = (chunk) => onChunk(chunk);
|
||||||
|
await invoke<void>("agent_send", {
|
||||||
|
sessionId,
|
||||||
|
prompt: message,
|
||||||
|
onReply: channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,6 +187,21 @@ export type DomainEvent =
|
|||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
| { type: "sprintDeleted"; sprintId: string }
|
| { type: "sprintDeleted"; sprintId: string }
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* A ticket AI assistant chat was opened (ticket #8). Mirror of the backend
|
||||||
|
* `DomainEventDto::TicketAssistantOpened`. `issueRef` is the bound ticket,
|
||||||
|
* `profileId` the runtime AI profile driving the assistant.
|
||||||
|
*/
|
||||||
|
type: "ticketAssistantOpened";
|
||||||
|
issueRef: string;
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** A ticket AI assistant chat was closed/disposed (ticket #8). */
|
||||||
|
type: "ticketAssistantClosed";
|
||||||
|
issueRef: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
/**
|
/**
|
||||||
* An intermediate assistant announcement emitted **during** an inter-agent
|
* An intermediate assistant announcement emitted **during** an inter-agent
|
||||||
@ -963,3 +978,25 @@ export interface TicketCarnet {
|
|||||||
carnet: string;
|
carnet: string;
|
||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ticket AI assistant chat session (mirror of the backend `TicketChatDto`,
|
||||||
|
* ticket #8). `sessionId` is the live structured session driving the assistant;
|
||||||
|
* `requester` is the assistant agent id; `issueRef` the bound ticket.
|
||||||
|
*/
|
||||||
|
export interface TicketChat {
|
||||||
|
sessionId: string;
|
||||||
|
requester: string;
|
||||||
|
issueRef: TicketRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One streamed chunk of an assistant turn (mirror of the backend `ReplyChunk`,
|
||||||
|
* tagged on `kind`). `textDelta` is an incremental text fragment; `toolActivity`
|
||||||
|
* a best-effort activity badge; `final` the deterministic end-of-turn chunk
|
||||||
|
* carrying the aggregated content — after it the turn is frozen.
|
||||||
|
*/
|
||||||
|
export type ReplyChunk =
|
||||||
|
| { kind: "textDelta"; text: string }
|
||||||
|
| { kind: "toolActivity"; label: string }
|
||||||
|
| { kind: "final"; content: string };
|
||||||
|
|||||||
178
frontend/src/features/tickets/TicketAssistantPanel.tsx
Normal file
178
frontend/src/features/tickets/TicketAssistantPanel.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Ticket AI assistant chat panel (ticket #8, F2).
|
||||||
|
*
|
||||||
|
* Consumes {@link useTicketAssistant} to drive a ticket-scoped assistant: pick
|
||||||
|
* an AI profile, open a read-only session bound to this ticket, then converse.
|
||||||
|
* The assistant can only read files and edit *this* ticket — its edits refresh
|
||||||
|
* the ticket automatically (see {@link useTicketDetail}). Purely presentational:
|
||||||
|
* all session/streaming logic lives in the hook.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Button, Spinner, cn } from "@/shared";
|
||||||
|
import { useTicketAssistant } from "./useTicketAssistant";
|
||||||
|
|
||||||
|
const selectClass = cn(
|
||||||
|
"h-9 rounded-md bg-raised px-3 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface TicketAssistantPanelProps {
|
||||||
|
projectId: string;
|
||||||
|
ticketRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketAssistantPanel({
|
||||||
|
projectId,
|
||||||
|
ticketRef,
|
||||||
|
}: TicketAssistantPanelProps) {
|
||||||
|
const vm = useTicketAssistant(projectId, ticketRef);
|
||||||
|
const [profileId, setProfileId] = useState("");
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
// Auto-scroll the transcript to the newest content as it streams in.
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [vm.turns]);
|
||||||
|
|
||||||
|
const open = vm.sessionId !== null;
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || vm.busy) return;
|
||||||
|
void vm.send(text);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{!open ? (
|
||||||
|
// ── Session opener ──
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="profil de l'assistant"
|
||||||
|
className={selectClass}
|
||||||
|
value={profileId}
|
||||||
|
disabled={vm.opening || vm.profiles.length === 0}
|
||||||
|
onChange={(e) => setProfileId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{vm.profiles.length === 0
|
||||||
|
? "Aucun profil disponible"
|
||||||
|
: "Choisir un profil IA…"}
|
||||||
|
</option>
|
||||||
|
{vm.profiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!profileId || vm.opening}
|
||||||
|
loading={vm.opening}
|
||||||
|
onClick={() => void vm.open(profileId)}
|
||||||
|
>
|
||||||
|
Ouvrir la conversation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// ── Live conversation ──
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
Conversation ouverte — l'assistant peut lire le projet et éditer ce
|
||||||
|
ticket.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.close()}
|
||||||
|
>
|
||||||
|
Fermer la conversation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex max-h-96 min-h-32 flex-col gap-2 overflow-auto rounded-md border border-border bg-raised/40 p-3"
|
||||||
|
>
|
||||||
|
{vm.turns.length === 0 ? (
|
||||||
|
<p className="m-auto text-xs text-muted">
|
||||||
|
Démarrez la conversation en décrivant ce que l'assistant doit
|
||||||
|
faire sur ce ticket.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
vm.turns.map((turn, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
turn.role === "user" ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm",
|
||||||
|
turn.role === "user"
|
||||||
|
? "bg-primary/15 text-content"
|
||||||
|
: "bg-surface text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{turn.text}
|
||||||
|
{turn.pending && (
|
||||||
|
<span className="ml-1 inline-flex items-center align-middle text-muted">
|
||||||
|
<Spinner size={12} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
aria-label="message à l'assistant"
|
||||||
|
className={cn(
|
||||||
|
"min-h-10 flex-1 resize-none rounded-md bg-raised p-2 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Votre message…"
|
||||||
|
value={draft}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!draft.trim() || vm.busy}
|
||||||
|
loading={vm.busy}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Envoyer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vm.error && (
|
||||||
|
<p role="alert" className="text-xs text-danger">
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import type { TicketLinkKind } from "@/domain";
|
|||||||
import { Button, Input, Spinner, cn } from "@/shared";
|
import { Button, Input, Spinner, cn } from "@/shared";
|
||||||
import { useTicketDetail } from "./useTicketDetail";
|
import { useTicketDetail } from "./useTicketDetail";
|
||||||
import { useProjectAgents } from "./useProjectAgents";
|
import { useProjectAgents } from "./useProjectAgents";
|
||||||
|
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||||
import {
|
import {
|
||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -498,6 +499,14 @@ export function TicketDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ── AI assistant chat (#8, F2) ── */}
|
||||||
|
<Section
|
||||||
|
title="Assistant IA"
|
||||||
|
hint="Conversez avec un agent IA pour faire évoluer ce ticket. L'agent lit le projet en lecture seule et ne peut éditer que ce ticket."
|
||||||
|
>
|
||||||
|
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import {
|
import {
|
||||||
MockAgentGateway,
|
MockAgentGateway,
|
||||||
|
MockProfileGateway,
|
||||||
MockSystemGateway,
|
MockSystemGateway,
|
||||||
MockTicketGateway,
|
MockTicketGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
@ -43,6 +44,18 @@ function renderView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedAssistantProfile(profile: MockProfileGateway) {
|
||||||
|
return profile.saveProfile({
|
||||||
|
id: "qa-assistant",
|
||||||
|
name: "QA Assistant",
|
||||||
|
command: "codex",
|
||||||
|
args: [],
|
||||||
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||||
|
detect: null,
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("MockTicketGateway", () => {
|
describe("MockTicketGateway", () => {
|
||||||
let system: MockSystemGateway;
|
let system: MockSystemGateway;
|
||||||
let ticket: MockTicketGateway;
|
let ticket: MockTicketGateway;
|
||||||
@ -427,6 +440,66 @@ describe("TicketsView", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens, streams, and closes the ticket AI assistant chat (#8)", async () => {
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "Assistant target" });
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await seedAssistantProfile(profile);
|
||||||
|
const gateways = { ticket, system, agent, profile } as unknown as Gateways;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TicketDetail
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
ticketRef={t.ref}
|
||||||
|
onClose={() => {}}
|
||||||
|
onOpenRef={() => {}}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
||||||
|
expect(within(detail).getByText("Assistant IA")).toBeTruthy();
|
||||||
|
|
||||||
|
const profileSelect = await within(detail).findByLabelText(
|
||||||
|
"profil de l'assistant",
|
||||||
|
);
|
||||||
|
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
|
||||||
|
fireEvent.click(within(detail).getByText("Ouvrir la conversation"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(detail).findByLabelText("message à l'assistant"),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.change(within(detail).getByLabelText("message à l'assistant"), {
|
||||||
|
target: { value: "Crée un plan de correction" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(detail).getByText("Envoyer"));
|
||||||
|
|
||||||
|
expect(await within(detail).findByText("Crée un plan de correction")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
await within(detail).findByText(
|
||||||
|
"Assistant: reçu « Crée un plan de correction ».",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(detail).getByText("Fermer la conversation"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
within(detail).queryByLabelText("message à l'assistant"),
|
||||||
|
).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(detail).queryByText("Crée un plan de correction"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
within(detail).queryByText("Assistant: reçu « Crée un plan de correction »."),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SprintManager (#11)", () => {
|
describe("SprintManager (#11)", () => {
|
||||||
|
|||||||
177
frontend/src/features/tickets/useTicketAssistant.ts
Normal file
177
frontend/src/features/tickets/useTicketAssistant.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* View-model hook for the ticket AI assistant chat (ticket #8, F2).
|
||||||
|
*
|
||||||
|
* Owns a ticket-scoped assistant session: it lists the AI profiles to drive the
|
||||||
|
* assistant, opens/closes the session via the {@link TicketGateway}, sends
|
||||||
|
* messages and folds the streamed {@link ReplyChunk}s into a user↔assistant
|
||||||
|
* transcript. The session is disposed on unmount (leaving the ticket) so no
|
||||||
|
* assistant outlives its editor.
|
||||||
|
*
|
||||||
|
* The displayed ticket refreshes on assistant edits automatically: those edits
|
||||||
|
* land as `Issue*` domain events which {@link useTicketDetail} already listens
|
||||||
|
* to — this hook does not need to wire that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/** One rendered turn of the assistant transcript. */
|
||||||
|
export interface AssistantTurn {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
/** True while an assistant turn is still streaming (no `final` yet). */
|
||||||
|
pending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketAssistantViewModel {
|
||||||
|
/** AI profiles available to drive the assistant. */
|
||||||
|
profiles: AgentProfile[];
|
||||||
|
/** Live session id once opened, else `null`. */
|
||||||
|
sessionId: string | null;
|
||||||
|
/** The user↔assistant transcript, oldest first. */
|
||||||
|
turns: AssistantTurn[];
|
||||||
|
/** True while opening the session. */
|
||||||
|
opening: boolean;
|
||||||
|
/** True while an assistant turn is streaming. */
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** Opens an assistant session bound to this ticket, driven by `profileId`. */
|
||||||
|
open: (profileId: string) => Promise<void>;
|
||||||
|
/** Closes/disposes the session and clears the transcript. */
|
||||||
|
close: () => Promise<void>;
|
||||||
|
/** Sends a message and streams the assistant reply into the transcript. */
|
||||||
|
send: (message: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketAssistant(
|
||||||
|
projectId: string,
|
||||||
|
issueRef: string,
|
||||||
|
): TicketAssistantViewModel {
|
||||||
|
const { ticket, profile } = useGateways();
|
||||||
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [turns, setTurns] = useState<AssistantTurn[]>([]);
|
||||||
|
const [opening, setOpening] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Mirror of `sessionId` for the unmount cleanup (which must not re-run when the
|
||||||
|
// id changes — only dispose whatever is live at teardown).
|
||||||
|
const sessionRef = useRef<string | null>(null);
|
||||||
|
sessionRef.current = sessionId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const list = (await profile?.listProfiles()) ?? [];
|
||||||
|
if (!cancelled) setProfiles(list);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(describe(e));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
async (profileId: string) => {
|
||||||
|
if (!profileId) return;
|
||||||
|
setOpening(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const chat = await ticket.openTicketChat(projectId, issueRef, profileId);
|
||||||
|
setSessionId(chat.sessionId);
|
||||||
|
setTurns([]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setOpening(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ticket, projectId, issueRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(async () => {
|
||||||
|
if (!sessionRef.current) return;
|
||||||
|
setSessionId(null);
|
||||||
|
setTurns([]);
|
||||||
|
try {
|
||||||
|
await ticket.closeTicketChat(projectId, issueRef);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
|
}, [ticket, projectId, issueRef]);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (message: string) => {
|
||||||
|
const text = message.trim();
|
||||||
|
if (!sessionId || !text || busy) return;
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
// Append the user turn and an empty, pending assistant turn to stream into.
|
||||||
|
setTurns((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", text },
|
||||||
|
{ role: "assistant", text: "", pending: true },
|
||||||
|
]);
|
||||||
|
const applyChunk = (chunk: ReplyChunk) => {
|
||||||
|
setTurns((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const i = next.length - 1;
|
||||||
|
if (i < 0 || next[i].role !== "assistant") return prev;
|
||||||
|
if (chunk.kind === "textDelta") {
|
||||||
|
next[i] = { ...next[i], text: next[i].text + chunk.text };
|
||||||
|
} else if (chunk.kind === "toolActivity") {
|
||||||
|
// Best-effort inline activity badge.
|
||||||
|
next[i] = { ...next[i], text: `${next[i].text}\n· ${chunk.label}` };
|
||||||
|
} else {
|
||||||
|
// `final`: freeze the turn with the aggregated content.
|
||||||
|
next[i] = { role: "assistant", text: chunk.content, pending: false };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (chunk.kind === "final") setBusy(false);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await ticket.sendTicketChat(sessionId, text, applyChunk);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ticket, sessionId, busy],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispose the session when leaving the ticket (unmount). Fire-and-forget.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (sessionRef.current) {
|
||||||
|
void ticket.closeTicketChat(projectId, issueRef).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [ticket, projectId, issueRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles,
|
||||||
|
sessionId,
|
||||||
|
turns,
|
||||||
|
opening,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
send,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -37,12 +37,14 @@ import type {
|
|||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
|
ReplyChunk,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
Sprint,
|
Sprint,
|
||||||
Template,
|
Template,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketChat,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
TicketList,
|
TicketList,
|
||||||
@ -829,6 +831,29 @@ export interface TicketGateway {
|
|||||||
* them); they fall back to the "no sprint" bucket (#11).
|
* them); they fall back to the "no sprint" bucket (#11).
|
||||||
*/
|
*/
|
||||||
deleteSprint(projectId: string, sprintId: string): Promise<void>;
|
deleteSprint(projectId: string, sprintId: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Opens an AI assistant chat bound to a ticket (ticket #8). Spawns a
|
||||||
|
* structured assistant session driven by `profileId`; returns the live
|
||||||
|
* session handle. One session per ticket — reopening supersedes.
|
||||||
|
*/
|
||||||
|
openTicketChat(
|
||||||
|
projectId: string,
|
||||||
|
issueRef: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<TicketChat>;
|
||||||
|
/** Closes (disposes) a ticket's assistant chat session (ticket #8). */
|
||||||
|
closeTicketChat(projectId: string, issueRef: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Sends a message on an open assistant session and streams the reply back as
|
||||||
|
* {@link ReplyChunk}s via `onChunk` (ticket #8). Resolves once the turn has
|
||||||
|
* started; the turn ends at the `final` chunk. Reuses the shared `agent_send`
|
||||||
|
* chat transport (the same as the structured chat cell).
|
||||||
|
*/
|
||||||
|
sendTicketChat(
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user