feat(tickets): création d'un assistant IA de ticket (backend + frontend)
Ajoute le chat assistant IA attaché à un ticket (#8). Backend Rust : - use cases OpenTicketAssistant/CloseTicketAssistant + tests - politique d'outils par agent (domain/agent_tool_policy) et policy MCP - store de contexte assistant + gabarit default_ticket_assistant.md + tests - events TicketAssistantOpened/Closed - commandes Tauri open_ticket_chat/close_ticket_chat et câblage state/lib/events Frontend : - gateway (ports, adapters ticket + mock, domain) - hook useTicketAssistant + composant TicketAssistantPanel - intégration dans TicketDetail Tests verts : vitest tickets.test.tsx (23), cargo test application::ticket_assistant (1), infrastructure::assistant_context_store (2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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()
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user