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 { if self.issue.reference() == issue_ref { Ok(self.issue.clone()) } else { Err(IssueStoreError::NotFound) } } async fn list( &self, _root: &ProjectPath, _filter: IssueListFilter, ) -> Result, 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 { unimplemented!() } async fn write_carnet( &self, _root: &ProjectPath, _issue_ref: IssueRef, _carnet: MarkdownDoc, _actor: IssueActor, _now_ms: u64, _expected_version: IssueVersion, ) -> Result { unimplemented!() } } struct FakeProfiles { profile: AgentProfile, } #[async_trait] impl ProfileStore for FakeProfiles { async fn list(&self) -> Result, 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 { Ok(true) } async fn mark_configured(&self) -> Result<(), StoreError> { Ok(()) } } #[derive(Default)] struct FakeAssistantContext { prepared: Mutex>, } #[async_trait] impl AssistantContextProvider for FakeAssistantContext { async fn prepare_ticket_assistant_context( &self, project: &Project, issue: &Issue, ) -> Result { 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>, } #[async_trait] impl AgentSession for FakeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { None } async fn send(&self, _prompt: &str) -> Result { Ok(Box::new(std::iter::empty())) } async fn shutdown(&self) -> Result<(), AgentSessionError> { *self.shutdowns.lock().unwrap() += 1; Ok(()) } } #[derive(Default)] struct FakeFactory { starts: Mutex)>>, shutdowns: Arc>, } #[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, 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>); 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 { self.0.lock().unwrap().get(requester).cloned() } fn clear_policy(&self, requester: &str) { self.0.lock().unwrap().remove(requester); } } #[derive(Default)] struct SpyBus(Mutex>); 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() )); }