Merge feature/ticket-ai-assistant into develop
Assistant IA de ticket (#8) : backend (use cases open/close, policy d'outils par agent, store de contexte assistant, events, commandes Tauri) et frontend (gateway, hook, panneau, intégration TicketDetail). Tests verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -50,6 +50,20 @@ pub enum DomainEventDto {
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// A ticket assistant chat was opened.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TicketAssistantOpened {
|
||||
/// Bound ticket reference.
|
||||
issue_ref: String,
|
||||
/// Runtime profile id.
|
||||
profile_id: String,
|
||||
},
|
||||
/// A ticket assistant chat was closed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TicketAssistantClosed {
|
||||
/// Bound ticket reference.
|
||||
issue_ref: String,
|
||||
},
|
||||
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
|
||||
/// "Envoyer" while `busy` is `true`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -551,6 +565,16 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
code: *code,
|
||||
},
|
||||
DomainEvent::TicketAssistantOpened {
|
||||
issue_ref,
|
||||
profile_id,
|
||||
} => Self::TicketAssistantOpened {
|
||||
issue_ref: issue_ref.to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
},
|
||||
DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed {
|
||||
issue_ref: issue_ref.to_string(),
|
||||
},
|
||||
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
busy: *busy,
|
||||
@ -1081,4 +1105,24 @@ mod tests {
|
||||
assert_eq!(json["to"], to.to_string());
|
||||
assert_eq!(json["version"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_assistant_events_relay_to_dto_and_wire() {
|
||||
let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9));
|
||||
let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
||||
profile_id,
|
||||
});
|
||||
let opened = serde_json::to_value(&opened).expect("serialisable");
|
||||
assert_eq!(opened["type"], "ticketAssistantOpened");
|
||||
assert_eq!(opened["issueRef"], "#7");
|
||||
assert_eq!(opened["profileId"], profile_id.to_string());
|
||||
|
||||
let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
||||
});
|
||||
let closed = serde_json::to_value(&closed).expect("serialisable");
|
||||
assert_eq!(closed["type"], "ticketAssistantClosed");
|
||||
assert_eq!(closed["issueRef"], "#7");
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,6 +166,8 @@ pub fn run() {
|
||||
commands::list_agents,
|
||||
tickets::ticket_create,
|
||||
tickets::ticket_read,
|
||||
tickets::open_ticket_chat,
|
||||
tickets::close_ticket_chat,
|
||||
tickets::ticket_list,
|
||||
tickets::ticket_update,
|
||||
tickets::ticket_read_carnet,
|
||||
|
||||
@ -15,39 +15,40 @@ use application::{
|
||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
||||
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
||||
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile,
|
||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
|
||||
DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines,
|
||||
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean,
|
||||
GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
||||
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||
ListAgentsInput, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles,
|
||||
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
|
||||
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
|
||||
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort,
|
||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, Embedder,
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
|
||||
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
|
||||
SprintStore, TemplateStore, WakeError, WakeReason,
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||
BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
|
||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
||||
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
||||
WakeError, WakeReason,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -65,16 +66,16 @@ use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
|
||||
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
@ -822,6 +823,12 @@ pub struct AppState {
|
||||
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
||||
/// MCP provider for the public `idea_ticket_*` tools.
|
||||
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
||||
/// Open an ephemeral AI assistant chat bound to one ticket.
|
||||
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
||||
/// Close an ephemeral AI assistant chat bound to one ticket.
|
||||
pub close_ticket_assistant: Arc<CloseTicketAssistant>,
|
||||
/// Shared MCP tool policy registry used by ticket assistant sessions.
|
||||
pub tool_policy_registry: Arc<ToolPolicyRegistry>,
|
||||
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||
pub pty_bridge: Arc<PtyBridge>,
|
||||
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
|
||||
@ -1279,6 +1286,26 @@ impl AppState {
|
||||
unlink: Arc::clone(&unlink_issues),
|
||||
list_sprints: Arc::clone(&list_sprints),
|
||||
});
|
||||
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
|
||||
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
||||
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
)) as Arc<dyn AssistantContextProvider>;
|
||||
let open_ticket_assistant = Arc::new(OpenTicketAssistant::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
assistant_context_provider,
|
||||
Arc::clone(&session_factory),
|
||||
Arc::clone(&structured_sessions),
|
||||
Arc::clone(&tool_policy_store),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let close_ticket_assistant = Arc::new(CloseTicketAssistant::new(
|
||||
Arc::clone(&structured_sessions),
|
||||
tool_policy_store,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
|
||||
// --- Project permissions (LP1) ---
|
||||
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
||||
@ -2178,6 +2205,9 @@ impl AppState {
|
||||
assign_ticket_to_sprint,
|
||||
unassign_ticket_from_sprint,
|
||||
ticket_tool_provider,
|
||||
open_ticket_assistant,
|
||||
close_ticket_assistant,
|
||||
tool_policy_registry,
|
||||
pty_bridge,
|
||||
structured_sessions,
|
||||
chat_bridge,
|
||||
@ -2337,7 +2367,8 @@ impl AppState {
|
||||
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
|
||||
.with_events(events)
|
||||
.with_ready_sink(ready_sink)
|
||||
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider)),
|
||||
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
|
||||
.with_tool_policies(Arc::clone(&self.tool_policy_registry)),
|
||||
endpoint,
|
||||
listener,
|
||||
project_id,
|
||||
|
||||
@ -14,15 +14,16 @@ use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput,
|
||||
CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput,
|
||||
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
|
||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
|
||||
CreateIssueInput, CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput,
|
||||
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput,
|
||||
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
|
||||
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
|
||||
ProjectId, SprintId, SprintStatus, SprintVersion,
|
||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
|
||||
Project, ProjectId, SprintId, SprintStatus, SprintVersion,
|
||||
};
|
||||
use infrastructure::{TicketToolError, TicketToolProvider};
|
||||
|
||||
@ -106,6 +107,29 @@ pub struct SprintListDto {
|
||||
pub items: Vec<SprintDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenTicketChatRequestDto {
|
||||
pub project_id: String,
|
||||
pub issue_ref: String,
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseTicketChatRequestDto {
|
||||
pub project_id: String,
|
||||
pub issue_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TicketChatDto {
|
||||
pub session_id: String,
|
||||
pub requester: String,
|
||||
pub issue_ref: String,
|
||||
}
|
||||
|
||||
/// Public link DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -588,6 +612,43 @@ pub async fn ticket_read(
|
||||
Ok(TicketDto::from_issue(issue, carnet))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_ticket_chat(
|
||||
request: OpenTicketChatRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TicketChatDto, ErrorDto> {
|
||||
let project = resolve_project(&state, &request.project_id).await?;
|
||||
let output = state
|
||||
.open_ticket_assistant
|
||||
.execute(OpenTicketAssistantInput {
|
||||
project,
|
||||
issue_ref: parse_ref_dto(&request.issue_ref)?,
|
||||
profile_id: parse_profile_id_dto(&request.profile_id)?,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(TicketChatDto {
|
||||
session_id: output.session_id.to_string(),
|
||||
requester: output.requester,
|
||||
issue_ref: output.issue_ref.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_ticket_chat(
|
||||
request: CloseTicketChatRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let _project = resolve_project(&state, &request.project_id).await?;
|
||||
state
|
||||
.close_ticket_assistant
|
||||
.execute(CloseTicketAssistantInput {
|
||||
issue_ref: parse_ref_dto(&request.issue_ref)?,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ticket_list(
|
||||
request: TicketListRequestDto,
|
||||
@ -1234,6 +1295,12 @@ fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
|
||||
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
|
||||
}
|
||||
|
||||
fn parse_profile_id_dto(raw: &str) -> Result<ProfileId, ErrorDto> {
|
||||
Uuid::parse_str(raw)
|
||||
.map(ProfileId::from_uuid)
|
||||
.map_err(|e| ErrorDto::invalid(format!("invalid profile id: {e}")))
|
||||
}
|
||||
|
||||
fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
|
||||
Uuid::parse_str(raw)
|
||||
.map(SprintId::from_uuid)
|
||||
|
||||
@ -30,6 +30,7 @@ pub mod skill;
|
||||
pub mod sprints;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
pub mod ticket_assistant;
|
||||
pub mod window;
|
||||
pub mod workstate;
|
||||
|
||||
@ -149,6 +150,10 @@ pub use terminal::{
|
||||
ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
|
||||
WriteToTerminalInput,
|
||||
};
|
||||
pub use ticket_assistant::{
|
||||
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
|
||||
OpenTicketAssistantOutput,
|
||||
};
|
||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
pub use workstate::{
|
||||
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
|
||||
|
||||
@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::ports::{AgentSession, PtyHandle};
|
||||
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
|
||||
use domain::{AgentId, IssueRef, NodeId, SessionId, SessionKind, TerminalSession};
|
||||
|
||||
/// Runtime family of a live agent session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@ -303,6 +303,12 @@ struct StructuredEntry {
|
||||
node_id: NodeId,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketAssistantEntry {
|
||||
session: Arc<dyn AgentSession>,
|
||||
requester: String,
|
||||
}
|
||||
|
||||
/// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5).
|
||||
///
|
||||
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
|
||||
@ -318,6 +324,7 @@ struct StructuredEntry {
|
||||
#[derive(Default)]
|
||||
pub struct StructuredSessions {
|
||||
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
|
||||
ticket_assistants: Mutex<HashMap<IssueRef, TicketAssistantEntry>>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for StructuredSessions {
|
||||
@ -339,6 +346,7 @@ impl StructuredSessions {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
ticket_assistants: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -365,6 +373,60 @@ impl StructuredSessions {
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
|
||||
.or_else(|| {
|
||||
self.ticket_assistants.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| e.session.id() == *id)
|
||||
.map(|e| Arc::clone(&e.session))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Enregistre une session assistant ticket éphémère, keyée par `issue_ref`.
|
||||
///
|
||||
/// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer
|
||||
/// hors verrou. Ces sessions ne sont pas des agents manifest et ne participent
|
||||
/// donc pas aux vues `live_agents`.
|
||||
pub fn insert_ticket_assistant(
|
||||
&self,
|
||||
issue_ref: IssueRef,
|
||||
requester: String,
|
||||
session: Arc<dyn AgentSession>,
|
||||
) -> Option<Arc<dyn AgentSession>> {
|
||||
self.ticket_assistants
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.insert(issue_ref, TicketAssistantEntry { session, requester }))
|
||||
.map(|entry| entry.session)
|
||||
}
|
||||
|
||||
/// Retourne la session assistant vivante pour `issue_ref`, si présente.
|
||||
#[must_use]
|
||||
pub fn ticket_assistant_session(&self, issue_ref: IssueRef) -> Option<Arc<dyn AgentSession>> {
|
||||
self.ticket_assistants
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(&issue_ref).map(|e| Arc::clone(&e.session)))
|
||||
}
|
||||
|
||||
/// Retourne l'identité MCP requester de l'assistant ticket, si présent.
|
||||
#[must_use]
|
||||
pub fn ticket_assistant_requester(&self, issue_ref: IssueRef) -> Option<String> {
|
||||
self.ticket_assistants
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(&issue_ref).map(|e| e.requester.clone()))
|
||||
}
|
||||
|
||||
/// Retire l'assistant ticket pour `issue_ref`.
|
||||
pub fn remove_ticket_assistant(
|
||||
&self,
|
||||
issue_ref: IssueRef,
|
||||
) -> Option<(Arc<dyn AgentSession>, String)> {
|
||||
self.ticket_assistants
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.remove(&issue_ref).map(|e| (e.session, e.requester)))
|
||||
}
|
||||
|
||||
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
|
||||
@ -460,6 +522,15 @@ impl StructuredSessions {
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.remove(id).map(|e| e.session))
|
||||
.or_else(|| {
|
||||
self.ticket_assistants.lock().ok().and_then(|mut m| {
|
||||
let issue_ref = m
|
||||
.iter()
|
||||
.find(|(_, e)| e.session.id() == *id)
|
||||
.map(|(issue_ref, _)| *issue_ref)?;
|
||||
m.remove(&issue_ref).map(|e| e.session)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne toutes les sessions vivantes (pour un arrêt global propre au
|
||||
@ -468,14 +539,31 @@ impl StructuredSessions {
|
||||
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().map(|e| Arc::clone(&e.session)).collect())
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| Arc::clone(&e.session))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.ticket_assistants
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| Arc::clone(&e.session))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Nombre de sessions structurées vivantes.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
+ self.ticket_assistants.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Si le registre est vide.
|
||||
|
||||
222
crates/application/src/ticket_assistant.rs
Normal file
222
crates/application/src/ticket_assistant.rs
Normal file
@ -0,0 +1,222 @@
|
||||
//! Use cases for ephemeral AI ticket editing assistants.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentSessionFactory, SessionPlan};
|
||||
use domain::{AgentProfile, ProjectPath};
|
||||
use domain::{
|
||||
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
|
||||
IssueRef, IssueStore, ProfileId, ProfileStore, Project, SandboxPlan, SessionId,
|
||||
};
|
||||
|
||||
use crate::terminal::StructuredSessions;
|
||||
use crate::AppError;
|
||||
|
||||
/// Opens an ephemeral ticket assistant chat session.
|
||||
pub struct OpenTicketAssistant {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
contexts: Arc<dyn AssistantContextProvider>,
|
||||
factory: Arc<dyn AgentSessionFactory>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
/// Input for [`OpenTicketAssistant`].
|
||||
pub struct OpenTicketAssistantInput {
|
||||
/// Project owning the ticket.
|
||||
pub project: Project,
|
||||
/// Ticket reference to bind.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Structured profile to use.
|
||||
pub profile_id: ProfileId,
|
||||
}
|
||||
|
||||
/// Output of [`OpenTicketAssistant`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenTicketAssistantOutput {
|
||||
/// Live structured session id, to be driven by `agent_send`.
|
||||
pub session_id: SessionId,
|
||||
/// MCP requester identity bound to this assistant.
|
||||
pub requester: String,
|
||||
/// Bound ticket reference.
|
||||
pub issue_ref: IssueRef,
|
||||
}
|
||||
|
||||
impl OpenTicketAssistant {
|
||||
/// Builds the use case.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
issues: Arc<dyn IssueStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
contexts: Arc<dyn AssistantContextProvider>,
|
||||
factory: Arc<dyn AgentSessionFactory>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
issues,
|
||||
profiles,
|
||||
contexts,
|
||||
factory,
|
||||
structured,
|
||||
policies,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the open flow.
|
||||
///
|
||||
/// Reopening an already-live assistant for the same ticket replaces the old
|
||||
/// session after the fresh one has started. This guarantees the assistant sees
|
||||
/// the latest ticket content/profile and keeps the invariant "one assistant per
|
||||
/// ticket" without leaving a gap if the new launch fails.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: OpenTicketAssistantInput,
|
||||
) -> Result<OpenTicketAssistantOutput, AppError> {
|
||||
let issue = self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
let profile = find_profile(&self.profiles, input.profile_id).await?;
|
||||
if !self.factory.supports(&profile) {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"profile {} cannot start a structured ticket assistant",
|
||||
input.profile_id
|
||||
)));
|
||||
}
|
||||
let prepared = self
|
||||
.contexts
|
||||
.prepare_ticket_assistant_context(&input.project, &issue)
|
||||
.await
|
||||
.map_err(|e| AppError::Store(e.to_string()))?;
|
||||
let sandbox = ticket_assistant_sandbox(&input.project.root, input.issue_ref);
|
||||
let session = self
|
||||
.factory
|
||||
.start(
|
||||
&profile,
|
||||
&prepared,
|
||||
&input.project.root,
|
||||
&SessionPlan::None,
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let session_id = session.id();
|
||||
let requester = session_id.to_string();
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec![
|
||||
"idea_ticket_read".to_owned(),
|
||||
"idea_ticket_update".to_owned(),
|
||||
"idea_ticket_update_carnet".to_owned(),
|
||||
],
|
||||
Some(input.issue_ref),
|
||||
true,
|
||||
);
|
||||
|
||||
if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) {
|
||||
self.policies.clear_policy(&old_requester);
|
||||
}
|
||||
let replaced = self.structured.insert_ticket_assistant(
|
||||
input.issue_ref,
|
||||
requester.clone(),
|
||||
Arc::clone(&session),
|
||||
);
|
||||
self.policies.set_policy(requester.clone(), policy);
|
||||
if let Some(old) = replaced {
|
||||
let _ = old.shutdown().await;
|
||||
}
|
||||
self.events.publish(DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: input.issue_ref,
|
||||
profile_id: input.profile_id,
|
||||
});
|
||||
|
||||
Ok(OpenTicketAssistantOutput {
|
||||
session_id,
|
||||
requester,
|
||||
issue_ref: input.issue_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes an ephemeral ticket assistant chat session.
|
||||
pub struct CloseTicketAssistant {
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
/// Input for [`CloseTicketAssistant`].
|
||||
pub struct CloseTicketAssistantInput {
|
||||
/// Ticket reference whose assistant should be closed.
|
||||
pub issue_ref: IssueRef,
|
||||
}
|
||||
|
||||
impl CloseTicketAssistant {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
structured,
|
||||
policies,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the close flow.
|
||||
pub async fn execute(&self, input: CloseTicketAssistantInput) -> Result<(), AppError> {
|
||||
let (session, requester) = self
|
||||
.structured
|
||||
.remove_ticket_assistant(input.issue_ref)
|
||||
.ok_or_else(|| AppError::NotFound(format!("ticket assistant {}", input.issue_ref)))?;
|
||||
self.policies.clear_policy(&requester);
|
||||
session.shutdown().await.map_err(AppError::from)?;
|
||||
self.events.publish(DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: input.issue_ref,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_profile(
|
||||
profiles: &Arc<dyn ProfileStore>,
|
||||
profile_id: ProfileId,
|
||||
) -> Result<AgentProfile, AppError> {
|
||||
profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == profile_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("profile {profile_id}")))
|
||||
}
|
||||
|
||||
fn ticket_assistant_sandbox(project_root: &ProjectPath, issue_ref: IssueRef) -> SandboxPlan {
|
||||
let run_dir = format!(
|
||||
"{}/.ideai/run/ticket-assistant-{}",
|
||||
project_root.as_str().trim_end_matches(['/', '\\']),
|
||||
issue_ref.number().get()
|
||||
);
|
||||
SandboxPlan::project_read_only(project_root.as_str().to_owned(), run_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sandbox_uses_project_read_only_preset() {
|
||||
let root = ProjectPath::new("/tmp/project").unwrap();
|
||||
let issue_ref = IssueRef::from(domain::IssueNumber::new(7).unwrap());
|
||||
let plan = ticket_assistant_sandbox(&root, issue_ref);
|
||||
assert_eq!(plan.allowed[0].abs_root, "/tmp/project");
|
||||
assert_eq!(plan.default_posture, domain::permission::Posture::Deny);
|
||||
}
|
||||
}
|
||||
339
crates/application/tests/ticket_assistant.rs
Normal file
339
crates/application/tests/ticket_assistant.rs
Normal file
@ -0,0 +1,339 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
|
||||
StructuredSessions,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan,
|
||||
};
|
||||
use domain::profile::StructuredAdapter;
|
||||
use domain::{
|
||||
AgentProfile, AgentToolPolicy, AgentToolPolicyStore, AssistantContextError,
|
||||
AssistantContextProvider, ContextInjection, DomainEvent, EventBus, EventStream, Issue,
|
||||
IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef,
|
||||
IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, PreparedContext,
|
||||
ProfileId, ProfileStore, Project, ProjectId, ProjectPath, RemoteRef, SessionId, StoreError,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn issue_ref(raw: &str) -> IssueRef {
|
||||
IssueRef::from_str(raw).unwrap()
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
"demo",
|
||||
ProjectPath::new("/tmp/project").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Claude",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
}
|
||||
|
||||
fn issue(number: u64) -> Issue {
|
||||
Issue::new(
|
||||
IssueId::from_uuid(Uuid::from_u128(number as u128)),
|
||||
IssueNumber::new(number).unwrap(),
|
||||
"Ticket title",
|
||||
MarkdownDoc::new("Ticket description"),
|
||||
IssueStatus::Open,
|
||||
IssuePriority::High,
|
||||
MarkdownDoc::new("Ticket carnet"),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
IssueActor::User,
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct FakeIssues {
|
||||
issue: Issue,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IssueStore for FakeIssues {
|
||||
async fn create(&self, _root: &ProjectPath, _issue: &Issue) -> Result<(), IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_by_ref(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
if self.issue.reference() == issue_ref {
|
||||
Ok(self.issue.clone())
|
||||
} else {
|
||||
Err(IssueStoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_filter: IssueListFilter,
|
||||
) -> Result<Vec<domain::IssueIndexEntry>, IssueStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue: &Issue,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<(), IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn read_carnet(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
) -> Result<IssueCarnet, IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn write_carnet(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_carnet: MarkdownDoc,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<IssueCarnet, IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProfiles {
|
||||
profile: AgentProfile,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(vec![self.profile.clone()])
|
||||
}
|
||||
|
||||
async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeAssistantContext {
|
||||
prepared: Mutex<Option<PreparedContext>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AssistantContextProvider for FakeAssistantContext {
|
||||
async fn prepare_ticket_assistant_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
issue: &Issue,
|
||||
) -> Result<PreparedContext, AssistantContextError> {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new(format!(
|
||||
"assistant\n{}\n{}\n{}",
|
||||
issue.reference(),
|
||||
issue.description.as_str(),
|
||||
issue.carnet.as_str()
|
||||
)),
|
||||
relative_path: "ticket-assistant.md".to_owned(),
|
||||
project_root: project.root.as_str().to_owned(),
|
||||
};
|
||||
*self.prepared.lock().unwrap() = Some(ctx.clone());
|
||||
Ok(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
shutdowns: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
*self.shutdowns.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFactory {
|
||||
starts: Mutex<Vec<(PreparedContext, SessionPlan, Option<domain::SandboxPlan>)>>,
|
||||
shutdowns: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSessionFactory for FakeFactory {
|
||||
fn supports(&self, _profile: &AgentProfile) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
sandbox: Option<&domain::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((ctx.clone(), session.clone(), sandbox.cloned()));
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: SessionId::new_random(),
|
||||
shutdowns: Arc::clone(&self.shutdowns),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePolicies(Mutex<HashMap<String, AgentToolPolicy>>);
|
||||
|
||||
impl AgentToolPolicyStore for FakePolicies {
|
||||
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
|
||||
self.0.lock().unwrap().insert(requester, policy);
|
||||
}
|
||||
|
||||
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
|
||||
self.0.lock().unwrap().get(requester).cloned()
|
||||
}
|
||||
|
||||
fn clear_policy(&self, requester: &str) {
|
||||
self.0.lock().unwrap().remove(requester);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
|
||||
let issue = issue(7);
|
||||
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let policies = Arc::new(FakePolicies::default());
|
||||
let events = Arc::new(SpyBus::default());
|
||||
let context = Arc::new(FakeAssistantContext::default());
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let open = OpenTicketAssistant::new(
|
||||
Arc::new(FakeIssues {
|
||||
issue: issue.clone(),
|
||||
}),
|
||||
Arc::new(FakeProfiles {
|
||||
profile: profile(profile_id),
|
||||
}),
|
||||
context.clone(),
|
||||
factory.clone(),
|
||||
structured.clone(),
|
||||
policies.clone(),
|
||||
events.clone(),
|
||||
);
|
||||
let close = CloseTicketAssistant::new(structured.clone(), policies.clone(), events.clone());
|
||||
|
||||
let output = open
|
||||
.execute(OpenTicketAssistantInput {
|
||||
project: project(),
|
||||
issue_ref: issue.reference(),
|
||||
profile_id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(structured.session(&output.session_id).is_some());
|
||||
let policy = policies.get_policy(&output.requester).expect("policy set");
|
||||
assert!(policy.permits("idea_ticket_read"));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue.reference()));
|
||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
|
||||
let prepared = context.prepared.lock().unwrap().clone().unwrap();
|
||||
assert!(prepared.content.as_str().contains("#7"));
|
||||
assert!(prepared.content.as_str().contains("Ticket description"));
|
||||
assert!(prepared.content.as_str().contains("Ticket carnet"));
|
||||
let starts = factory.starts.lock().unwrap();
|
||||
assert!(matches!(starts[0].1, SessionPlan::None));
|
||||
assert!(starts[0]
|
||||
.2
|
||||
.as_ref()
|
||||
.is_some_and(|p| { p.default_posture == domain::permission::Posture::Deny }));
|
||||
drop(starts);
|
||||
|
||||
close
|
||||
.execute(CloseTicketAssistantInput {
|
||||
issue_ref: issue.reference(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(structured.session(&output.session_id).is_none());
|
||||
assert!(policies.get_policy(&output.requester).is_none());
|
||||
assert_eq!(*factory.shutdowns.lock().unwrap(), 1);
|
||||
let events = events.0.lock().unwrap();
|
||||
assert!(matches!(
|
||||
events[0],
|
||||
DomainEvent::TicketAssistantOpened { issue_ref, .. } if issue_ref == issue.reference()
|
||||
));
|
||||
assert!(matches!(
|
||||
events[1],
|
||||
DomainEvent::TicketAssistantClosed { issue_ref } if issue_ref == issue.reference()
|
||||
));
|
||||
}
|
||||
113
crates/domain/src/agent_tool_policy.rs
Normal file
113
crates/domain/src/agent_tool_policy.rs
Normal file
@ -0,0 +1,113 @@
|
||||
//! MCP tool policy for constrained assistant sessions.
|
||||
//!
|
||||
//! This is a pure domain value. It models what a requester may do through the
|
||||
//! MCP surface; it does not know how the MCP server enforces it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::issue::IssueRef;
|
||||
|
||||
/// Tool policy attached to one agent/requester.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentToolPolicy {
|
||||
/// Explicitly allowed MCP tool names.
|
||||
pub allow: Vec<String>,
|
||||
/// Optional issue boundary for ticket mutations.
|
||||
pub bound_issue: Option<IssueRef>,
|
||||
/// Whether tools outside [`Self::allow`] are denied.
|
||||
pub deny_others: bool,
|
||||
}
|
||||
|
||||
impl AgentToolPolicy {
|
||||
/// Builds a tool policy.
|
||||
#[must_use]
|
||||
pub fn new(allow: Vec<String>, bound_issue: Option<IssueRef>, deny_others: bool) -> Self {
|
||||
Self {
|
||||
allow,
|
||||
bound_issue,
|
||||
deny_others,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when `tool` is allowed by the allowlist/default rule.
|
||||
#[must_use]
|
||||
pub fn permits(&self, tool: &str) -> bool {
|
||||
self.allow.iter().any(|allowed| allowed == tool) || !self.deny_others
|
||||
}
|
||||
|
||||
/// Returns true when `tool` may mutate the supplied ticket reference.
|
||||
///
|
||||
/// The method first applies the tool allowlist. Then, for known ticket mutation
|
||||
/// tools, an optional [`Self::bound_issue`] fences the mutation to that single
|
||||
/// issue reference.
|
||||
#[must_use]
|
||||
pub fn permits_ticket_mutation(&self, tool: &str, issue_ref: IssueRef) -> bool {
|
||||
if !self.permits(tool) {
|
||||
return false;
|
||||
}
|
||||
if !is_ticket_mutation_tool(tool) {
|
||||
return true;
|
||||
}
|
||||
self.bound_issue.map_or(true, |bound| bound == issue_ref)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ticket_mutation_tool(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
"idea_ticket_update"
|
||||
| "idea_ticket_update_status"
|
||||
| "idea_ticket_update_priority"
|
||||
| "idea_ticket_update_carnet"
|
||||
| "idea_ticket_link"
|
||||
| "idea_ticket_unlink"
|
||||
| "idea_ticket_create"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn issue_ref(raw: &str) -> IssueRef {
|
||||
IssueRef::from_str(raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_allowlisted_tool_when_deny_others_is_true() {
|
||||
let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, true);
|
||||
assert!(policy.permits("idea_ticket_read"));
|
||||
assert!(!policy.permits("idea_ask_agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_others_false_allows_non_listed_tools() {
|
||||
let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, false);
|
||||
assert!(policy.permits("idea_ticket_read"));
|
||||
assert!(policy.permits("idea_ask_agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_mutation_is_bound_to_the_configured_issue() {
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec!["idea_ticket_update".to_owned()],
|
||||
Some(issue_ref("#7")),
|
||||
true,
|
||||
);
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7")));
|
||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_mutation_requires_the_tool_itself_to_be_allowed() {
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
Some(issue_ref("#7")),
|
||||
true,
|
||||
);
|
||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7")));
|
||||
}
|
||||
}
|
||||
@ -183,6 +183,18 @@ pub enum DomainEvent {
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// A ticket assistant session was opened for an issue.
|
||||
TicketAssistantOpened {
|
||||
/// Bound issue.
|
||||
issue_ref: IssueRef,
|
||||
/// Runtime profile used by the assistant.
|
||||
profile_id: ProfileId,
|
||||
},
|
||||
/// A ticket assistant session was closed for an issue.
|
||||
TicketAssistantClosed {
|
||||
/// Bound issue.
|
||||
issue_ref: IssueRef,
|
||||
},
|
||||
/// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on
|
||||
/// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an
|
||||
/// explicit signal). Discrete, low-frequency beacon relayed to the front so the
|
||||
@ -531,6 +543,14 @@ mod tests {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn profile(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn issue_ref(n: u64) -> IssueRef {
|
||||
IssueRef::from(crate::IssueNumber::new(n).unwrap())
|
||||
}
|
||||
|
||||
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
|
||||
|
||||
#[test]
|
||||
@ -638,6 +658,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_assistant_opened_constructs_and_compares() {
|
||||
let ev = DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: issue_ref(7),
|
||||
profile_id: profile(9),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: issue_ref(7),
|
||||
profile_id: profile(9),
|
||||
}
|
||||
);
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: issue_ref(8),
|
||||
profile_id: profile(9),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_assistant_closed_constructs_and_compares() {
|
||||
let ev = DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: issue_ref(7),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: issue_ref(7),
|
||||
}
|
||||
);
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: issue_ref(8),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_session_limit_variants_are_not_equal() {
|
||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod agent_tool_policy;
|
||||
pub mod background_task;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
@ -79,6 +80,8 @@ pub use project::{Project, ProjectPath};
|
||||
|
||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
|
||||
pub use agent_tool_policy::AgentToolPolicy;
|
||||
|
||||
pub use background_task::{
|
||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
||||
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||
@ -180,7 +183,8 @@ pub use orchestrator::{
|
||||
};
|
||||
|
||||
pub use ports::{
|
||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
|
||||
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||
|
||||
@ -28,6 +28,7 @@ use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agent::AgentManifest;
|
||||
use crate::agent_tool_policy::AgentToolPolicy;
|
||||
use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
@ -123,6 +124,43 @@ pub struct PreparedContext {
|
||||
pub project_root: String,
|
||||
}
|
||||
|
||||
/// Errors returned while preparing the IdeA-owned ticket assistant context.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum AssistantContextError {
|
||||
/// The context source was missing and no embedded default could be used.
|
||||
#[error("assistant context not found")]
|
||||
NotFound,
|
||||
/// The context could not be read or rendered.
|
||||
#[error("assistant context store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// Provides the IdeA-owned context used by ticket assistant sessions.
|
||||
#[async_trait]
|
||||
pub trait AssistantContextProvider: Send + Sync {
|
||||
/// Renders a prepared context for a ticket assistant bound to `issue`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AssistantContextError`] when the context cannot be read or rendered.
|
||||
async fn prepare_ticket_assistant_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
issue: &Issue,
|
||||
) -> Result<PreparedContext, AssistantContextError>;
|
||||
}
|
||||
|
||||
/// Stores requester-scoped MCP tool policies for ephemeral assistant sessions.
|
||||
pub trait AgentToolPolicyStore: Send + Sync {
|
||||
/// Sets or replaces the policy for `requester`.
|
||||
fn set_policy(&self, requester: String, policy: AgentToolPolicy);
|
||||
|
||||
/// Returns the policy for `requester`, if any.
|
||||
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy>;
|
||||
|
||||
/// Clears the policy for `requester`.
|
||||
fn clear_policy(&self, requester: &str);
|
||||
}
|
||||
|
||||
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
||||
/// on-disk transcript format (CONTEXT §T6).
|
||||
///
|
||||
|
||||
@ -145,6 +145,31 @@ pub struct SandboxPlan {
|
||||
pub default_posture: Posture,
|
||||
}
|
||||
|
||||
impl SandboxPlan {
|
||||
/// Builds the ticket-assistant preset: read-only access to the project root,
|
||||
/// denied writes by default, while keeping the assistant run directory
|
||||
/// writable so CLI session metadata/resume files can still function.
|
||||
#[must_use]
|
||||
pub fn project_read_only(project_root: impl Into<String>, run_dir: impl Into<String>) -> Self {
|
||||
let project_root = project_root.into();
|
||||
let run_dir = run_dir.into();
|
||||
let mut allowed = vec![PathGrant {
|
||||
abs_root: project_root,
|
||||
access: PathAccess::RO,
|
||||
}];
|
||||
if !run_dir.trim().is_empty() {
|
||||
allowed.push(PathGrant {
|
||||
abs_root: run_dir,
|
||||
access: PathAccess::RO | PathAccess::RW,
|
||||
});
|
||||
}
|
||||
Self {
|
||||
allowed,
|
||||
default_posture: Posture::Deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots
|
||||
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
|
||||
pub struct SandboxContext<'a> {
|
||||
@ -465,6 +490,19 @@ mod tests {
|
||||
assert!(!g.access.contains(PathAccess::RW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_read_only_preset_allows_project_read_and_run_dir_write_only() {
|
||||
let plan = SandboxPlan::project_read_only(ROOT, "/home/anthony/.ideai/run/assistant");
|
||||
let project = grant(&plan, ROOT).expect("project root granted");
|
||||
assert_eq!(project.access, PathAccess::RO);
|
||||
assert!(!project.access.contains(PathAccess::RW));
|
||||
|
||||
let run = grant(&plan, "/home/anthony/.ideai/run/assistant").expect("run dir granted");
|
||||
assert!(run.access.contains(PathAccess::RO));
|
||||
assert!(run.access.contains(PathAccess::RW));
|
||||
assert_eq!(plan.default_posture, Posture::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_delete_map_to_rw() {
|
||||
let e = eff(
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
# Ticket Assistant
|
||||
|
||||
You are IdeA's ticket editing assistant. Help improve the bound ticket only.
|
||||
Read the project context when needed, propose concise changes, and use ticket
|
||||
tools only for the ticket explicitly injected below.
|
||||
76
crates/infrastructure/src/assistant/mod.rs
Normal file
76
crates/infrastructure/src/assistant/mod.rs
Normal file
@ -0,0 +1,76 @@
|
||||
//! Ticket assistant infrastructure adapters.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc,
|
||||
PreparedContext, Project, RemotePath,
|
||||
};
|
||||
|
||||
const ASSISTANT_DIR: &str = "assistant";
|
||||
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
|
||||
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");
|
||||
|
||||
/// File-backed provider for the IdeA-owned ticket assistant context.
|
||||
#[derive(Clone)]
|
||||
pub struct FsAssistantContextStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsAssistantContextStore {
|
||||
/// Builds the store from an injected filesystem and app-data directory.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&self, rel: &str) -> String {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
|
||||
fn context_path(&self) -> RemotePath {
|
||||
RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}")))
|
||||
}
|
||||
|
||||
async fn read_base_context(&self) -> Result<String, AssistantContextError> {
|
||||
let path = self.context_path();
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => {
|
||||
String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string()))
|
||||
}
|
||||
Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()),
|
||||
Err(e) => Err(AssistantContextError::Store(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AssistantContextProvider for FsAssistantContextStore {
|
||||
async fn prepare_ticket_assistant_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
issue: &Issue,
|
||||
) -> Result<PreparedContext, AssistantContextError> {
|
||||
let mut content = self.read_base_context().await?;
|
||||
content.push_str("\n\n## Bound Ticket\n\n");
|
||||
content.push_str(&format!("- Ref: {}\n", issue.reference()));
|
||||
content.push_str(&format!("- Title: {}\n", issue.title));
|
||||
content.push_str("\n### Description\n\n");
|
||||
content.push_str(issue.description.as_str());
|
||||
content.push_str("\n\n### Carnet\n\n");
|
||||
content.push_str(issue.carnet.as_str());
|
||||
content.push('\n');
|
||||
|
||||
Ok(PreparedContext {
|
||||
content: MarkdownDoc::new(content),
|
||||
relative_path: TICKET_ASSISTANT_FILE.to_owned(),
|
||||
project_root: project.root.as_str().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod assistant;
|
||||
pub mod background_task;
|
||||
pub mod clock;
|
||||
pub mod conversation;
|
||||
@ -39,6 +40,7 @@ pub mod sprints;
|
||||
pub mod store;
|
||||
pub mod timeparse;
|
||||
|
||||
pub use assistant::FsAssistantContextStore;
|
||||
pub use background_task::{
|
||||
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
|
||||
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
|
||||
@ -64,6 +66,7 @@ pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
ToolPolicyRegistry,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
//! child process).
|
||||
|
||||
pub mod jsonrpc;
|
||||
pub mod policy;
|
||||
pub mod server;
|
||||
pub mod tickets;
|
||||
pub mod tools;
|
||||
@ -37,6 +38,7 @@ pub mod transport;
|
||||
pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use policy::ToolPolicyRegistry;
|
||||
pub use server::McpServer;
|
||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||
|
||||
76
crates/infrastructure/src/orchestrator/mcp/policy.rs
Normal file
76
crates/infrastructure/src/orchestrator/mcp/policy.rs
Normal file
@ -0,0 +1,76 @@
|
||||
//! In-memory MCP tool policy registry.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use domain::AgentToolPolicy;
|
||||
use domain::AgentToolPolicyStore;
|
||||
|
||||
/// Stores per-requester MCP tool policies for live assistant sessions.
|
||||
#[derive(Default)]
|
||||
pub struct ToolPolicyRegistry {
|
||||
policies: RwLock<HashMap<String, AgentToolPolicy>>,
|
||||
}
|
||||
|
||||
impl ToolPolicyRegistry {
|
||||
/// Builds an empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets or replaces the policy for `requester`.
|
||||
pub fn set(&self, requester: impl Into<String>, policy: AgentToolPolicy) {
|
||||
self.policies
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(requester.into(), policy);
|
||||
}
|
||||
|
||||
/// Returns the policy for `requester`, if any.
|
||||
#[must_use]
|
||||
pub fn get(&self, requester: &str) -> Option<AgentToolPolicy> {
|
||||
self.policies.read().unwrap().get(requester).cloned()
|
||||
}
|
||||
|
||||
/// Clears the policy for `requester`.
|
||||
pub fn clear(&self, requester: &str) {
|
||||
self.policies.write().unwrap().remove(requester);
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentToolPolicyStore for ToolPolicyRegistry {
|
||||
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
|
||||
self.set(requester, policy);
|
||||
}
|
||||
|
||||
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
|
||||
self.get(requester)
|
||||
}
|
||||
|
||||
fn clear_policy(&self, requester: &str) {
|
||||
self.clear(requester);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use domain::IssueRef;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn set_get_clear_roundtrips_by_requester() {
|
||||
let registry = ToolPolicyRegistry::new();
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
Some("#7".parse::<IssueRef>().unwrap()),
|
||||
true,
|
||||
);
|
||||
registry.set("assistant-1", policy.clone());
|
||||
assert_eq!(registry.get("assistant-1"), Some(policy));
|
||||
assert_eq!(registry.get("assistant-2"), None);
|
||||
registry.clear("assistant-1");
|
||||
assert_eq!(registry.get("assistant-1"), None);
|
||||
}
|
||||
}
|
||||
@ -16,11 +16,12 @@
|
||||
//! at the composition root (M3), exactly like the watcher — no application logic is
|
||||
//! duplicated here.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use application::OrchestratorService;
|
||||
use domain::{DomainEvent, OrchestrationSource, Project};
|
||||
use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@ -28,6 +29,7 @@ use super::jsonrpc::{
|
||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||
JSONRPC_VERSION,
|
||||
};
|
||||
use super::policy::ToolPolicyRegistry;
|
||||
use super::tickets::TicketToolProvider;
|
||||
use super::tools::{self, ToolMapError};
|
||||
|
||||
@ -65,6 +67,8 @@ pub struct McpServer {
|
||||
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||
/// provider maps those calls to application/domain `Issue` use cases.
|
||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@ -80,6 +84,7 @@ impl McpServer {
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ticket_tools: None,
|
||||
tool_policies: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +116,13 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the requester-scoped MCP tool policy registry.
|
||||
#[must_use]
|
||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||
self.tool_policies = Some(tool_policies);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a per-connection clone of this server tagged with the connected
|
||||
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||
///
|
||||
@ -128,6 +140,7 @@ impl McpServer {
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ticket_tools: self.ticket_tools.clone(),
|
||||
tool_policies: self.tool_policies.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,8 +313,17 @@ impl McpServer {
|
||||
|
||||
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
||||
fn tools_list_result(&self) -> Value {
|
||||
let policy = self
|
||||
.tool_policies
|
||||
.as_ref()
|
||||
.and_then(|registry| registry.get(&self.requester));
|
||||
let tools: Vec<Value> = tools::catalogue()
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
policy
|
||||
.as_ref()
|
||||
.map_or(true, |policy| policy.permits(t.name))
|
||||
})
|
||||
.map(|t| {
|
||||
json!({
|
||||
"name": t.name,
|
||||
@ -324,6 +346,14 @@ impl McpServer {
|
||||
.to_owned();
|
||||
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
|
||||
if let Some(policy) = self
|
||||
.tool_policies
|
||||
.as_ref()
|
||||
.and_then(|registry| registry.get(&self.requester))
|
||||
{
|
||||
self.enforce_tool_policy(&policy, &name, &arguments)?;
|
||||
}
|
||||
|
||||
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
|
||||
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
|
||||
let started = Instant::now();
|
||||
@ -437,6 +467,47 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_tool_policy(
|
||||
&self,
|
||||
policy: &AgentToolPolicy,
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
) -> Result<(), JsonRpcError> {
|
||||
if !policy.permits(name) {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!(
|
||||
"tool `{name}` is not permitted for requester {}",
|
||||
self.requester
|
||||
),
|
||||
));
|
||||
}
|
||||
if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") {
|
||||
let raw_ref = arguments
|
||||
.get("ref")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `{name}` requires a ticket ref under the active policy"),
|
||||
)
|
||||
})?;
|
||||
let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("invalid ticket ref: {e}"),
|
||||
)
|
||||
})?;
|
||||
if !policy.permits_ticket_mutation(name, issue_ref) {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `{name}` is not permitted for ticket {issue_ref}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
|
||||
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
||||
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
|
||||
|
||||
110
crates/infrastructure/tests/assistant_context_store.rs
Normal file
110
crates/infrastructure/tests/assistant_context_store.rs
Normal file
@ -0,0 +1,110 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority,
|
||||
IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath,
|
||||
};
|
||||
use infrastructure::{FsAssistantContextStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("idea-assistant-context-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.join("app-data").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn project_root(&self) -> ProjectPath {
|
||||
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn project(root: ProjectPath) -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
"demo",
|
||||
root,
|
||||
domain::remote::RemoteRef::local(),
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn issue(number: u64) -> Issue {
|
||||
Issue::new(
|
||||
IssueId::from_uuid(Uuid::from_u128(number as u128)),
|
||||
IssueNumber::new(number).unwrap(),
|
||||
"Clarify onboarding",
|
||||
MarkdownDoc::new("Initial description body"),
|
||||
IssueStatus::Open,
|
||||
IssuePriority::High,
|
||||
MarkdownDoc::new("Carnet notes"),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
IssueActor::User,
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn store(app_data_dir: String) -> FsAssistantContextStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsAssistantContextStore::new(fs, app_data_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_context_is_embedded_and_injects_the_ticket() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(tmp.project_root());
|
||||
let store = store(tmp.app_data_dir());
|
||||
|
||||
let ctx = store
|
||||
.prepare_ticket_assistant_context(&project, &issue(7))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = ctx.content.as_str();
|
||||
assert!(body.contains("# Ticket Assistant"));
|
||||
assert!(body.contains("- Ref: #7"));
|
||||
assert!(body.contains("- Title: Clarify onboarding"));
|
||||
assert!(body.contains("Initial description body"));
|
||||
assert!(body.contains("Carnet notes"));
|
||||
assert_eq!(ctx.project_root, project.root.as_str());
|
||||
assert_eq!(ctx.relative_path, "ticket-assistant.md");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_data_override_replaces_the_embedded_default() {
|
||||
let tmp = TempDir::new();
|
||||
let app_data_dir = tmp.app_data_dir();
|
||||
let override_path = PathBuf::from(&app_data_dir)
|
||||
.join("assistant")
|
||||
.join("ticket-assistant.md");
|
||||
std::fs::create_dir_all(override_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&override_path, "# Custom assistant").unwrap();
|
||||
|
||||
let project = project(tmp.project_root());
|
||||
let store = store(app_data_dir);
|
||||
let ctx = store
|
||||
.prepare_ticket_assistant_context(&project, &issue(8))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = ctx.content.as_str();
|
||||
assert!(body.starts_with("# Custom assistant"));
|
||||
assert!(!body.contains("You are IdeA's ticket editing assistant"));
|
||||
assert!(body.contains("- Ref: #8"));
|
||||
}
|
||||
@ -21,6 +21,7 @@
|
||||
//! `OrchestratorRequest::validate` (typed error, **no** dispatch).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -44,7 +45,7 @@ use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::terminal::{SessionKind, TerminalSession};
|
||||
use domain::{PtySize, SessionId};
|
||||
use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
@ -55,7 +56,7 @@ use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock,
|
||||
SystemMillisClock, ToolPolicyRegistry,
|
||||
};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
@ -591,6 +592,73 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_is_filtered_for_requester_with_tool_policy() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec![
|
||||
"idea_ticket_read".to_owned(),
|
||||
"idea_ticket_update".to_owned(),
|
||||
"idea_ticket_update_carnet".to_owned(),
|
||||
],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
|
||||
}))
|
||||
.unwrap();
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "got error: {:?}", response.error);
|
||||
let result = response.result.expect("result");
|
||||
let tools = result["tools"].as_array().expect("tools array");
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"idea_ticket_read",
|
||||
"idea_ticket_update",
|
||||
"idea_ticket_update_carnet"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requester_without_tool_policy_keeps_the_full_tools_list() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
let server = server(service)
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("ordinary-agent");
|
||||
|
||||
let raw = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
|
||||
}))
|
||||
.unwrap();
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "got error: {:?}", response.error);
|
||||
let result = response.result.expect("result");
|
||||
let names: Vec<&str> = result["tools"]
|
||||
.as_array()
|
||||
.expect("tools array")
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&"idea_ask_agent"));
|
||||
assert!(names.contains(&"idea_ticket_update"));
|
||||
assert!(names.len() > 3, "unfiltered requester got {names:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -685,6 +753,82 @@ async fn update_context_and_create_skill_calls_succeed() {
|
||||
assert_eq!(r.result.expect("result")["isError"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_outside_allowlist_is_rejected_for_policy_requester() {
|
||||
let contexts = FakeContexts::new();
|
||||
contexts.seed_agent("architect");
|
||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = tools_call(
|
||||
11,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "please handle this" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
let error = response.error.expect("policy rejection expected");
|
||||
assert_eq!(error.code, error_codes::INVALID_PARAMS);
|
||||
assert!(
|
||||
error.message.contains("not permitted"),
|
||||
"got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(response.result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec!["idea_ticket_update".to_owned()],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||
let server = server(service)
|
||||
.with_ticket_tools(ticket_tools.clone())
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = tools_call(
|
||||
12,
|
||||
"idea_ticket_update",
|
||||
json!({
|
||||
"ref": "#8",
|
||||
"expectedVersion": 1,
|
||||
"title": "must not pass"
|
||||
}),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
let error = response.error.expect("policy rejection expected");
|
||||
assert_eq!(error.code, error_codes::INVALID_PARAMS);
|
||||
assert!(
|
||||
error.message.contains("#8"),
|
||||
"message should name the rejected issue; got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(
|
||||
ticket_tools.calls().is_empty(),
|
||||
"policy rejection must happen before ticket provider dispatch"
|
||||
);
|
||||
assert_eq!(ticket_tools.mutation_attempts(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -35,12 +35,14 @@ import type {
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
Skill,
|
||||
ReplyChunk,
|
||||
SkillScope,
|
||||
Sprint,
|
||||
Template,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
TicketLink,
|
||||
TicketLinkKind,
|
||||
TicketList,
|
||||
@ -1816,6 +1818,9 @@ export class MockTicketGateway implements TicketGateway {
|
||||
private counters = new Map<string, number>();
|
||||
private sprints = new Map<string, Map<string, Sprint>>();
|
||||
private sprintCounters = new Map<string, number>();
|
||||
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
||||
private chatSessions = new Map<string, string>();
|
||||
private chatCounter = 0;
|
||||
|
||||
constructor(private readonly system?: MockSystemGateway) {}
|
||||
|
||||
@ -2235,6 +2240,51 @@ export class MockTicketGateway implements TicketGateway {
|
||||
this.system?.emit({ type: "sprintDeleted", sprintId });
|
||||
}
|
||||
|
||||
async openTicketChat(
|
||||
projectId: string,
|
||||
issueRef: string,
|
||||
profileId: string,
|
||||
): Promise<TicketChat> {
|
||||
// Ensure the ticket exists (mirrors the backend resolving the ref).
|
||||
this.require(projectId, issueRef);
|
||||
const sessionId = `mock-ticket-chat-${++this.chatCounter}`;
|
||||
this.chatSessions.set(issueRef, sessionId);
|
||||
this.system?.emit({ type: "ticketAssistantOpened", issueRef, profileId });
|
||||
return { sessionId, requester: "ticket-assistant", issueRef };
|
||||
}
|
||||
|
||||
async closeTicketChat(_projectId: string, issueRef: string): Promise<void> {
|
||||
if (this.chatSessions.delete(issueRef)) {
|
||||
this.system?.emit({ type: "ticketAssistantClosed", issueRef });
|
||||
}
|
||||
}
|
||||
|
||||
async sendTicketChat(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
if (![...this.chatSessions.values()].includes(sessionId)) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `chat session ${sessionId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
// Simulate a streamed assistant turn: a couple of text deltas, then the
|
||||
// deterministic `final` chunk. Delivered across microtasks (like a real
|
||||
// Channel), so a consumer that awaits sees the whole turn.
|
||||
const content = `Assistant: reçu « ${message} ».`;
|
||||
const chunks: ReplyChunk[] = [
|
||||
{ kind: "textDelta", text: "Assistant: reçu " },
|
||||
{ kind: "textDelta", text: `« ${message} ».` },
|
||||
{ kind: "final", content },
|
||||
];
|
||||
for (const chunk of chunks) {
|
||||
await Promise.resolve();
|
||||
onChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
private requireSprint(projectId: string, sprintId: string): Sprint {
|
||||
const sprint = this.projectSprints(projectId).get(sprintId);
|
||||
if (!sprint) {
|
||||
|
||||
@ -10,13 +10,15 @@
|
||||
* {@link isTicketVersionConflict} to branch on it in the UI.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
GatewayError,
|
||||
ReplyChunk,
|
||||
Sprint,
|
||||
Ticket,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
TicketLinkKind,
|
||||
TicketList,
|
||||
} from "@/domain";
|
||||
@ -182,4 +184,38 @@ export class TauriTicketGateway implements TicketGateway {
|
||||
request: { projectId, sprintId },
|
||||
});
|
||||
}
|
||||
|
||||
async openTicketChat(
|
||||
projectId: string,
|
||||
issueRef: string,
|
||||
profileId: string,
|
||||
): Promise<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;
|
||||
}
|
||||
| { type: "sprintDeleted"; sprintId: string }
|
||||
| {
|
||||
/**
|
||||
* A ticket AI assistant chat was opened (ticket #8). Mirror of the backend
|
||||
* `DomainEventDto::TicketAssistantOpened`. `issueRef` is the bound ticket,
|
||||
* `profileId` the runtime AI profile driving the assistant.
|
||||
*/
|
||||
type: "ticketAssistantOpened";
|
||||
issueRef: string;
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
/** A ticket AI assistant chat was closed/disposed (ticket #8). */
|
||||
type: "ticketAssistantClosed";
|
||||
issueRef: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An intermediate assistant announcement emitted **during** an inter-agent
|
||||
@ -963,3 +978,25 @@ export interface TicketCarnet {
|
||||
carnet: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket AI assistant chat session (mirror of the backend `TicketChatDto`,
|
||||
* ticket #8). `sessionId` is the live structured session driving the assistant;
|
||||
* `requester` is the assistant agent id; `issueRef` the bound ticket.
|
||||
*/
|
||||
export interface TicketChat {
|
||||
sessionId: string;
|
||||
requester: string;
|
||||
issueRef: TicketRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* One streamed chunk of an assistant turn (mirror of the backend `ReplyChunk`,
|
||||
* tagged on `kind`). `textDelta` is an incremental text fragment; `toolActivity`
|
||||
* a best-effort activity badge; `final` the deterministic end-of-turn chunk
|
||||
* carrying the aggregated content — after it the turn is frozen.
|
||||
*/
|
||||
export type ReplyChunk =
|
||||
| { kind: "textDelta"; text: string }
|
||||
| { kind: "toolActivity"; label: string }
|
||||
| { kind: "final"; content: string };
|
||||
|
||||
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 { useTicketDetail } from "./useTicketDetail";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -498,6 +499,14 @@ export function TicketDetail({
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
} 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", () => {
|
||||
let system: MockSystemGateway;
|
||||
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)", () => {
|
||||
|
||||
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,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ReplyChunk,
|
||||
Skill,
|
||||
SkillScope,
|
||||
Sprint,
|
||||
Template,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketChat,
|
||||
TicketCarnet,
|
||||
TicketLinkKind,
|
||||
TicketList,
|
||||
@ -829,6 +831,29 @@ export interface TicketGateway {
|
||||
* them); they fall back to the "no sprint" bucket (#11).
|
||||
*/
|
||||
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