Files
IdeA/crates/application/tests/ticket_assistant.rs
Blomios 8570adb8e0 fix(ticket-assistant): édition de ticket via les tools MCP idea_ticket_* (#27)
L'assistant IA d'édition de ticket éditait les fichiers du ticket en direct,
hors de tout contrôle. Il passe désormais par les tools MCP idea_ticket_* :
préparation d'un environnement structuré dédié et policy d'enforcement scopée
au ticket courant, de sorte que l'assistant ne peut agir que sur son ticket
via la surface MCP plutôt que sur le système de fichiers.

Couvert par de nouveaux tests QA (mcp_server, assistant_context_store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:10:23 +02:00

414 lines
12 KiB
Rust

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, RuntimeError, SessionPlan,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
};
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 delete(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
) -> 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)
}
}
#[derive(Default)]
struct FakeEnvironmentPreparer {
calls: Mutex<Vec<(IssueRef, String, PreparedContext)>>,
}
#[async_trait]
impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer {
async fn prepare_ticket_assistant(
&self,
_project: &Project,
issue_ref: IssueRef,
_profile: &AgentProfile,
prepared: &PreparedContext,
requester: &str,
) -> Result<StructuredSessionEnvironment, RuntimeError> {
self.calls
.lock()
.unwrap()
.push((issue_ref, requester.to_owned(), prepared.clone()));
Ok(StructuredSessionEnvironment {
cwd: ProjectPath::new("/tmp/app-data/assistant/tickets/1/7").unwrap(),
env: vec![(
"CODEX_HOME".to_owned(),
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned(),
)],
})
}
}
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,
ProjectPath,
SessionPlan,
Vec<(String, String)>,
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,
env: &[(String, String)],
sandbox: Option<&domain::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts.lock().unwrap().push((
ctx.clone(),
cwd.clone(),
session.clone(),
env.to_vec(),
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 environment = Arc::new(FakeEnvironmentPreparer::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(),
environment.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());
assert_eq!(
output.requester,
"ticket-assistant:00000000000000000000000000000001:7"
);
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_status", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_update_priority", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_update_carnet", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_link", issue.reference()));
assert!(policy.permits_ticket_mutation("idea_ticket_unlink", issue.reference()));
assert!(!policy.permits("idea_ticket_create"));
assert!(!policy.permits("idea_ticket_list"));
assert!(!policy.permits("idea_ask_agent"));
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 environment_calls = environment.calls.lock().unwrap();
assert_eq!(environment_calls.len(), 1);
assert_eq!(environment_calls[0].0, issue.reference());
assert_eq!(environment_calls[0].1, output.requester);
drop(environment_calls);
let starts = factory.starts.lock().unwrap();
assert_eq!(starts[0].1.as_str(), "/tmp/app-data/assistant/tickets/1/7");
assert!(matches!(starts[0].2, SessionPlan::None));
assert_eq!(
starts[0].3,
vec![(
"CODEX_HOME".to_owned(),
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned()
)]
);
assert!(starts[0].4.is_none());
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()
));
}