use std::path::PathBuf; use std::sync::Arc; use application::McpRuntime; use async_trait::async_trait; use domain::ports::{SessionPlan, StructuredSessionEnvironmentPreparer}; use domain::profile::{McpCapability, McpConfigStrategy, McpTransport}; use domain::{ AgentProfile, AgentRuntime, AssistantContextProvider, ContextInjection, ContextInjectionPlan, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, IssueStatus, MarkdownDoc, PreparedContext, ProfileId, Project, ProjectId, ProjectPath, RemotePath, RuntimeError, SpawnSpec, }; use infrastructure::{ FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer, }; 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 = Arc::new(LocalFileSystem::new()); FsAssistantContextStore::new(fs, app_data_dir) } struct FakeRuntime; #[async_trait] impl AgentRuntime for FakeRuntime { async fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( &self, _profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, _session: &SessionPlan, ) -> Result { Ok(SpawnSpec { command: "assistant-cli".to_owned(), args: Vec::new(), cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::File { // Deliberately looks like the real ticket carnet path. The preparer // must still materialise it under the isolated assistant cwd, never // under the project root. target: ".ideai/tickets/7/carnet.md".to_owned(), }), sandbox: None, }) } } fn mcp_profile() -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(9)), "Claude", "claude", Vec::new(), ContextInjection::stdin(), None, "{projectRoot}", None, ) .unwrap() .with_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json").unwrap(), McpTransport::Stdio, )) } #[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")); } #[tokio::test] async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_data() { let tmp = TempDir::new(); let fs: Arc = Arc::new(LocalFileSystem::new()); let app_data_dir = tmp.app_data_dir(); let project = project(tmp.project_root()); let project_carnet = PathBuf::from(project.root.as_str()) .join(".ideai") .join("tickets") .join("7") .join("carnet.md"); let prepared = PreparedContext { content: MarkdownDoc::new("assistant-only context"), relative_path: "ticket-assistant.md".to_owned(), project_root: project.root.as_str().to_owned(), }; let requester = "ticket-assistant:00000000000000000000000000000001:7".to_owned(); let preparer = TicketAssistantEnvironmentPreparer::new( fs.clone(), app_data_dir.clone(), Arc::new(FakeRuntime), Arc::new(move |_, _| { Some(McpRuntime { exe: "/opt/idea/idea".to_owned(), endpoint: "127.0.0.1:4567".to_owned(), project_id: "00000000000000000000000000000001".to_owned(), requester: requester.clone(), }) }), ); let env = preparer .prepare_ticket_assistant( &project, issue(7).reference(), &mcp_profile(), &prepared, "ticket-assistant:00000000000000000000000000000001:7", ) .await .unwrap(); let run_dir = PathBuf::from(&app_data_dir) .join("assistant") .join("tickets") .join("00000000000000000000000000000001") .join("7"); assert_eq!(env.cwd.as_str(), run_dir.to_string_lossy()); let isolated_carnet = run_dir .join(".ideai") .join("tickets") .join("7") .join("carnet.md"); let isolated_mcp = run_dir.join(".mcp.json"); assert_eq!( fs.read(&RemotePath::new( isolated_carnet.to_string_lossy().into_owned() )) .await .unwrap(), b"assistant-only context" ); let mcp = String::from_utf8( fs.read(&RemotePath::new( isolated_mcp.to_string_lossy().into_owned(), )) .await .unwrap(), ) .unwrap(); assert!(mcp.contains(r#""idea""#)); assert!(mcp.contains(r#""--requester""#)); assert!(mcp.contains("ticket-assistant:00000000000000000000000000000001:7")); assert!( !project_carnet.exists(), "assistant context must not be written into the real project ticket carnet" ); }