Merge feature/ticket27-ticket-assistant-mcp-tools into develop

fix(ticket-assistant): édition de ticket via les tools MCP idea_ticket_* (#27)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 09:10:31 +02:00
10 changed files with 873 additions and 40 deletions

View File

@ -49,8 +49,9 @@ use domain::ports::{
BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
ToolInvoker, WakeError, WakeReason, WindowStateStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
WindowStateStore,
}; };
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -76,10 +77,10 @@ use infrastructure::{
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime,
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_ONNX_ENABLED, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
}; };
use crate::chat::ChatBridge; use crate::chat::ChatBridge;
@ -1355,10 +1356,26 @@ impl AppState {
Arc::clone(&fs_port), Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(), app_data_dir.to_string_lossy().into_owned(),
)) as Arc<dyn AssistantContextProvider>; )) as Arc<dyn AssistantContextProvider>;
let assistant_environment = Arc::new(TicketAssistantEnvironmentPreparer::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
Arc::clone(&runtime_port),
Arc::new(|project: &Project, requester: &str| {
Some(McpRuntime {
exe: crate::mcp_endpoint::idea_exe_path()?,
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg()
.to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: requester.to_owned(),
})
}),
)) as Arc<dyn StructuredSessionEnvironmentPreparer>;
let open_ticket_assistant = Arc::new(OpenTicketAssistant::new( let open_ticket_assistant = Arc::new(OpenTicketAssistant::new(
Arc::clone(&issue_store_port), Arc::clone(&issue_store_port),
Arc::clone(&profile_store_port), Arc::clone(&profile_store_port),
assistant_context_provider, assistant_context_provider,
assistant_environment,
Arc::clone(&session_factory), Arc::clone(&session_factory),
Arc::clone(&structured_sessions), Arc::clone(&structured_sessions),
Arc::clone(&tool_policy_store), Arc::clone(&tool_policy_store),

View File

@ -2,7 +2,7 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{AgentSessionFactory, SessionPlan}; use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer};
use domain::AgentProfile; use domain::AgentProfile;
use domain::{ use domain::{
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus, AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
@ -17,6 +17,7 @@ pub struct OpenTicketAssistant {
issues: Arc<dyn IssueStore>, issues: Arc<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>, profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>, contexts: Arc<dyn AssistantContextProvider>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>, factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>, structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>, policies: Arc<dyn AgentToolPolicyStore>,
@ -52,6 +53,7 @@ impl OpenTicketAssistant {
issues: Arc<dyn IssueStore>, issues: Arc<dyn IssueStore>,
profiles: Arc<dyn ProfileStore>, profiles: Arc<dyn ProfileStore>,
contexts: Arc<dyn AssistantContextProvider>, contexts: Arc<dyn AssistantContextProvider>,
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
factory: Arc<dyn AgentSessionFactory>, factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>, structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>, policies: Arc<dyn AgentToolPolicyStore>,
@ -61,6 +63,7 @@ impl OpenTicketAssistant {
issues, issues,
profiles, profiles,
contexts, contexts,
environment,
factory, factory,
structured, structured,
policies, policies,
@ -94,39 +97,74 @@ impl OpenTicketAssistant {
.prepare_ticket_assistant_context(&input.project, &issue) .prepare_ticket_assistant_context(&input.project, &issue)
.await .await
.map_err(|e| AppError::Store(e.to_string()))?; .map_err(|e| AppError::Store(e.to_string()))?;
let requester = ticket_assistant_requester(&input.project, input.issue_ref);
let had_existing = self
.structured
.ticket_assistant_requester(input.issue_ref)
.is_some();
let policy = AgentToolPolicy::new(
vec![
"idea_ticket_read".to_owned(),
"idea_ticket_update".to_owned(),
"idea_ticket_update_status".to_owned(),
"idea_ticket_update_priority".to_owned(),
"idea_ticket_read_carnet".to_owned(),
"idea_ticket_update_carnet".to_owned(),
"idea_ticket_link".to_owned(),
"idea_ticket_unlink".to_owned(),
],
Some(input.issue_ref),
true,
);
self.policies.set_policy(requester.clone(), policy);
let environment = match self
.environment
.prepare_ticket_assistant(
&input.project,
input.issue_ref,
&profile,
&prepared,
&requester,
)
.await
{
Ok(environment) => environment,
Err(err) => {
if !had_existing {
self.policies.clear_policy(&requester);
}
return Err(AppError::from(err));
}
};
let session = self let session = self
.factory .factory
.start( .start(
&profile, &profile,
&prepared, &prepared,
&input.project.root, &environment.cwd,
&SessionPlan::None, &SessionPlan::None,
&[], &environment.env,
None, None,
) )
.await .await
.map_err(AppError::from)?; .map_err(|err| {
if !had_existing {
self.policies.clear_policy(&requester);
}
AppError::from(err)
})?;
let session_id = session.id(); 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) { if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) {
self.policies.clear_policy(&old_requester); if old_requester != requester {
self.policies.clear_policy(&old_requester);
}
} }
let replaced = self.structured.insert_ticket_assistant( let replaced = self.structured.insert_ticket_assistant(
input.issue_ref, input.issue_ref,
requester.clone(), requester.clone(),
Arc::clone(&session), Arc::clone(&session),
); );
self.policies.set_policy(requester.clone(), policy);
if let Some(old) = replaced { if let Some(old) = replaced {
let _ = old.shutdown().await; let _ = old.shutdown().await;
} }
@ -143,6 +181,14 @@ impl OpenTicketAssistant {
} }
} }
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {
format!(
"ticket-assistant:{}:{}",
project.id.as_uuid().simple(),
issue_ref.number().get()
)
}
/// Closes an ephemeral ticket assistant chat session. /// Closes an ephemeral ticket assistant chat session.
pub struct CloseTicketAssistant { pub struct CloseTicketAssistant {
structured: Arc<StructuredSessions>, structured: Arc<StructuredSessions>,

View File

@ -8,7 +8,8 @@ use application::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan, AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
}; };
use domain::profile::StructuredAdapter; use domain::profile::StructuredAdapter;
use domain::{ use domain::{
@ -189,6 +190,35 @@ impl AssistantContextProvider for FakeAssistantContext {
} }
} }
#[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 { struct FakeSession {
id: SessionId, id: SessionId,
shutdowns: Arc<Mutex<usize>>, shutdowns: Arc<Mutex<usize>>,
@ -216,7 +246,15 @@ impl AgentSession for FakeSession {
#[derive(Default)] #[derive(Default)]
struct FakeFactory { struct FakeFactory {
starts: Mutex<Vec<(PreparedContext, SessionPlan, Option<domain::SandboxPlan>)>>, starts: Mutex<
Vec<(
PreparedContext,
ProjectPath,
SessionPlan,
Vec<(String, String)>,
Option<domain::SandboxPlan>,
)>,
>,
shutdowns: Arc<Mutex<usize>>, shutdowns: Arc<Mutex<usize>>,
} }
@ -230,15 +268,18 @@ impl AgentSessionFactory for FakeFactory {
&self, &self,
_profile: &AgentProfile, _profile: &AgentProfile,
ctx: &PreparedContext, ctx: &PreparedContext,
_cwd: &ProjectPath, cwd: &ProjectPath,
session: &SessionPlan, session: &SessionPlan,
_env: &[(String, String)], env: &[(String, String)],
sandbox: Option<&domain::SandboxPlan>, sandbox: Option<&domain::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts self.starts.lock().unwrap().push((
.lock() ctx.clone(),
.unwrap() cwd.clone(),
.push((ctx.clone(), session.clone(), sandbox.cloned())); session.clone(),
env.to_vec(),
sandbox.cloned(),
));
Ok(Arc::new(FakeSession { Ok(Arc::new(FakeSession {
id: SessionId::new_random(), id: SessionId::new_random(),
shutdowns: Arc::clone(&self.shutdowns), shutdowns: Arc::clone(&self.shutdowns),
@ -284,6 +325,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
let policies = Arc::new(FakePolicies::default()); let policies = Arc::new(FakePolicies::default());
let events = Arc::new(SpyBus::default()); let events = Arc::new(SpyBus::default());
let context = Arc::new(FakeAssistantContext::default()); let context = Arc::new(FakeAssistantContext::default());
let environment = Arc::new(FakeEnvironmentPreparer::default());
let factory = Arc::new(FakeFactory::default()); let factory = Arc::new(FakeFactory::default());
let open = OpenTicketAssistant::new( let open = OpenTicketAssistant::new(
Arc::new(FakeIssues { Arc::new(FakeIssues {
@ -293,6 +335,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
profile: profile(profile_id), profile: profile(profile_id),
}), }),
context.clone(), context.clone(),
environment.clone(),
factory.clone(), factory.clone(),
structured.clone(), structured.clone(),
policies.clone(), policies.clone(),
@ -310,17 +353,42 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
.unwrap(); .unwrap();
assert!(structured.session(&output.session_id).is_some()); 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"); let policy = policies.get_policy(&output.requester).expect("policy set");
assert!(policy.permits("idea_ticket_read")); 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.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"))); assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
let prepared = context.prepared.lock().unwrap().clone().unwrap(); let prepared = context.prepared.lock().unwrap().clone().unwrap();
assert!(prepared.content.as_str().contains("#7")); assert!(prepared.content.as_str().contains("#7"));
assert!(prepared.content.as_str().contains("Ticket description")); assert!(prepared.content.as_str().contains("Ticket description"));
assert!(prepared.content.as_str().contains("Ticket carnet")); 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(); let starts = factory.starts.lock().unwrap();
assert!(matches!(starts[0].1, SessionPlan::None)); assert_eq!(starts[0].1.as_str(), "/tmp/app-data/assistant/tickets/1/7");
assert!(starts[0].2.is_none()); 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); drop(starts);
close close

View File

@ -202,6 +202,7 @@ pub use ports::{
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore,
WindowStateStore, WindowStateStore,
}; };

View File

@ -128,6 +128,15 @@ pub struct PreparedContext {
pub project_root: String, pub project_root: String,
} }
/// Prepared environment for a structured assistant session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuredSessionEnvironment {
/// Isolated cwd where the structured CLI must run.
pub cwd: ProjectPath,
/// Environment variables to pass to the structured session.
pub env: Vec<(String, String)>,
}
/// Errors returned while preparing the IdeA-owned ticket assistant context. /// Errors returned while preparing the IdeA-owned ticket assistant context.
#[derive(Debug, Clone, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AssistantContextError { pub enum AssistantContextError {
@ -153,6 +162,23 @@ pub trait AssistantContextProvider: Send + Sync {
) -> Result<PreparedContext, AssistantContextError>; ) -> Result<PreparedContext, AssistantContextError>;
} }
/// Prepares the isolated run environment for a structured ticket assistant.
#[async_trait]
pub trait StructuredSessionEnvironmentPreparer: Send + Sync {
/// Materialises context and MCP configuration for a ticket assistant session.
///
/// # Errors
/// [`RuntimeError`] when the structured environment cannot be prepared.
async fn prepare_ticket_assistant(
&self,
project: &Project,
issue_ref: IssueRef,
profile: &AgentProfile,
prepared: &PreparedContext,
requester: &str,
) -> Result<StructuredSessionEnvironment, RuntimeError>;
}
/// Stores requester-scoped MCP tool policies for ephemeral assistant sessions. /// Stores requester-scoped MCP tool policies for ephemeral assistant sessions.
pub trait AgentToolPolicyStore: Send + Sync { pub trait AgentToolPolicyStore: Send + Sync {
/// Sets or replaces the policy for `requester`. /// Sets or replaces the policy for `requester`.

View File

@ -2,16 +2,26 @@
use std::sync::Arc; use std::sync::Arc;
use application::McpRuntime;
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::SessionPlan;
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::{ use domain::{
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc, AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
PreparedContext, Project, RemotePath, ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
PreparedContext, Project, ProjectPath, RemotePath, RuntimeError, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer,
}; };
use serde_json::{json, Map, Value};
const ASSISTANT_DIR: &str = "assistant"; const ASSISTANT_DIR: &str = "assistant";
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md"; const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md"); const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");
/// Resolves the live MCP runtime facts for a ticket assistant requester.
pub type TicketAssistantMcpRuntimeResolver =
dyn Fn(&Project, &str) -> Option<McpRuntime> + Send + Sync;
/// File-backed provider for the IdeA-owned ticket assistant context. /// File-backed provider for the IdeA-owned ticket assistant context.
#[derive(Clone)] #[derive(Clone)]
pub struct FsAssistantContextStore { pub struct FsAssistantContextStore {
@ -74,3 +84,400 @@ impl AssistantContextProvider for FsAssistantContextStore {
}) })
} }
} }
/// File-backed environment preparer for structured ticket assistant sessions.
#[derive(Clone)]
pub struct TicketAssistantEnvironmentPreparer {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
runtime: Arc<dyn AgentRuntime>,
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
}
impl TicketAssistantEnvironmentPreparer {
/// Builds the preparer from filesystem, app-data dir, runtime and MCP resolver.
#[must_use]
pub fn new(
fs: Arc<dyn FileSystem>,
app_data_dir: impl Into<String>,
runtime: Arc<dyn AgentRuntime>,
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
runtime,
mcp_runtime,
}
}
fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result<ProjectPath, RuntimeError> {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
let project_id = project.id.as_uuid().simple();
let issue_number = issue_ref.number().get();
ProjectPath::new(format!(
"{base}/{ASSISTANT_DIR}/tickets/{project_id}/{issue_number}"
))
.map_err(|e| RuntimeError::Invocation(e.to_string()))
}
async fn create_dir(&self, path: &str) -> Result<(), RuntimeError> {
self.fs
.create_dir_all(&RemotePath::new(path.to_owned()))
.await
.map_err(|e| RuntimeError::Invocation(e.to_string()))
}
async fn write_file(&self, path: &str, bytes: &[u8]) -> Result<(), RuntimeError> {
if let Some(parent) = parent_abs(path) {
self.create_dir(parent).await?;
}
self.fs
.write(&RemotePath::new(path.to_owned()), bytes)
.await
.map_err(|e| RuntimeError::Invocation(e.to_string()))
}
async fn read_optional(&self, path: &str) -> Result<Option<String>, RuntimeError> {
match self.fs.read(&RemotePath::new(path.to_owned())).await {
Ok(bytes) => String::from_utf8(bytes)
.map(Some)
.map_err(|e| RuntimeError::Invocation(e.to_string())),
Err(FsError::NotFound(_)) => Ok(None),
Err(e) => Err(RuntimeError::Invocation(e.to_string())),
}
}
async fn materialise_context(
&self,
plan: Option<ContextInjectionPlan>,
cwd: &ProjectPath,
prepared: &PreparedContext,
env: &mut Vec<(String, String)>,
) -> Result<(), RuntimeError> {
match plan {
Some(ContextInjectionPlan::File { target }) => {
self.write_file(&join(cwd, &target), prepared.content.as_str().as_bytes())
.await?;
}
Some(ContextInjectionPlan::Env { var }) => {
let path = join(cwd, &prepared.relative_path);
self.write_file(&path, prepared.content.as_str().as_bytes())
.await?;
env.push((var, path));
}
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {
let path = join(cwd, &prepared.relative_path);
self.write_file(&path, prepared.content.as_str().as_bytes())
.await?;
}
}
Ok(())
}
async fn materialise_mcp(
&self,
project: &Project,
profile: &AgentProfile,
cwd: &ProjectPath,
requester: &str,
env: &mut Vec<(String, String)>,
) -> Result<(), RuntimeError> {
let Some(mcp) = &profile.mcp else {
return Ok(());
};
let runtime = (self.mcp_runtime)(project, requester);
match &mcp.config {
McpConfigStrategy::ConfigFile { target } => {
let declaration = mcp_server_wiring(mcp.transport, runtime.as_ref()).to_mcp_json();
self.write_file(&join(cwd, target), declaration.as_bytes())
.await?;
}
McpConfigStrategy::TomlConfigHome { target, home_env } => {
let path = join(cwd, target);
let declaration =
mcp_server_wiring(mcp.transport, runtime.as_ref()).to_config_toml();
let existing = self.read_optional(&path).await?;
let rendered = codex_config_toml(
existing.as_deref(),
&declaration,
cwd.as_str(),
project.root.as_str(),
);
self.write_file(&path, rendered.as_bytes()).await?;
env.push((home_env.clone(), parent_dir(cwd, target)));
}
McpConfigStrategy::OpenCodeConfig { target } => {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return Ok(());
}
let Some(opencode) = profile.opencode.as_ref() else {
return Ok(());
};
let config_path = join(cwd, target);
let opencode_home = join(cwd, ".opencode");
let xdg_config = format!("{opencode_home}/config");
let xdg_data = format!("{opencode_home}/data");
let xdg_cache = format!("{opencode_home}/cache");
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
self.create_dir(dir).await?;
}
let body = opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
.to_string();
self.write_file(&config_path, body.as_bytes()).await?;
env.extend([
("OPENCODE_CONFIG".to_owned(), config_path),
("HOME".to_owned(), opencode_home),
("XDG_CONFIG_HOME".to_owned(), xdg_config),
("XDG_DATA_HOME".to_owned(), xdg_data),
("XDG_CACHE_HOME".to_owned(), xdg_cache),
("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()),
]);
}
McpConfigStrategy::Flag { flag } => {
env.push((flag.clone(), cwd.as_str().to_owned()));
}
McpConfigStrategy::Env { var } => {
env.push((var.clone(), cwd.as_str().to_owned()));
}
}
Ok(())
}
}
#[async_trait]
impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer {
async fn prepare_ticket_assistant(
&self,
project: &Project,
issue_ref: IssueRef,
profile: &AgentProfile,
prepared: &PreparedContext,
requester: &str,
) -> Result<StructuredSessionEnvironment, RuntimeError> {
let run_dir = self.run_dir(project, issue_ref)?;
self.create_dir(run_dir.as_str()).await?;
let spec =
self.runtime
.prepare_invocation(profile, prepared, &run_dir, &SessionPlan::None)?;
let mut env = spec.env;
self.materialise_context(spec.context_plan, &spec.cwd, prepared, &mut env)
.await?;
self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env)
.await?;
Ok(StructuredSessionEnvironment { cwd: spec.cwd, env })
}
}
fn mcp_server_wiring(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
) -> McpServerWiring {
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
McpServerWiring::new(command, args, transport)
}
fn opencode_config_json(
config: &domain::profile::OpenCodeConfig,
project_root: &str,
runtime: Option<&McpRuntime>,
) -> Value {
let model = config.model.as_str();
let opencode_model = format!("llamacpp/{model}");
let mut root = Map::new();
root.insert(
"$schema".to_owned(),
Value::String("https://opencode.ai/config.json".to_owned()),
);
root.insert("model".to_owned(), Value::String(opencode_model.clone()));
let mut options = Map::new();
options.insert("baseURL".to_owned(), Value::String(config.base_url.clone()));
if let Some(api_key) = config.api_key.as_ref() {
options.insert("apiKey".to_owned(), Value::String(api_key.clone()));
}
root.insert(
"provider".to_owned(),
json!({
"llamacpp": {
"npm": "@ai-sdk/openai-compatible",
"name": "llama.cpp",
"options": options,
"models": {
model: {
"name": opencode_model,
"tool_call": true,
"reasoning": config.reasoning_enabled(),
"attachment": config.attachment_enabled()
}
}
}
}),
);
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
let command_array = std::iter::once(command)
.chain(args)
.map(Value::String)
.collect::<Vec<_>>();
root.insert(
"mcp".to_owned(),
json!({
"idea": {
"type": "local",
"command": command_array,
"cwd": project_root,
"enabled": true,
"timeout": 15000
}
}),
);
root.insert(
"permission".to_owned(),
json!({
"bash": "ask",
"edit": "ask"
}),
);
root.insert(
"disabled_providers".to_owned(),
json!(["anthropic", "openai", "gemini", "ollama"]),
);
Value::Object(root)
}
fn codex_config_toml(
existing: Option<&str>,
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
) -> String {
let mut text = replace_toml_table_block(
existing.unwrap_or_default(),
"mcp_servers.idea",
mcp_declaration.trim_end(),
);
text = ensure_codex_project_trust(&text, run_dir);
text = ensure_codex_project_trust(&text, project_root);
if !text.ends_with('\n') {
text.push('\n');
}
text
}
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
let header = format!("[{table}]");
let mut out = Vec::new();
let mut skipping = false;
let mut inserted = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed == header {
if !inserted {
out.extend(replacement.lines().map(ToOwned::to_owned));
inserted = true;
}
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
skipping = false;
}
if !skipping {
out.push(line.to_owned());
}
}
if !inserted {
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
out.push(String::new());
}
out.extend(replacement.lines().map(ToOwned::to_owned));
}
out.join("\n")
}
fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
if path.is_empty() {
return existing.to_owned();
}
let header = format!(r#"[projects.{}]"#, toml_quoted(path));
if existing.lines().any(|line| line.trim() == header) {
return existing.to_owned();
}
let mut text = existing.trim_end().to_owned();
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str(&header);
text.push_str("\ntrust_level = \"trusted\"\n");
text
}
fn toml_quoted(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn join(base: &ProjectPath, rel: &str) -> String {
let base = base.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}
fn parent_abs(path: &str) -> Option<&str> {
path.rsplit_once(['/', '\\'])
.map(|(parent, _)| parent)
.filter(|parent| !parent.is_empty())
}
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
let full = join(base, rel);
match full.rsplit_once(['/', '\\']) {
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
}
}

View File

@ -41,7 +41,7 @@ pub mod sprints;
pub mod store; pub mod store;
pub mod timeparse; pub mod timeparse;
pub use assistant::FsAssistantContextStore; pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
pub use background_task::{ pub use background_task::{
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink, bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome, BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,

View File

@ -482,7 +482,15 @@ impl McpServer {
), ),
)); ));
} }
if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") { if matches!(
name,
"idea_ticket_update"
| "idea_ticket_update_status"
| "idea_ticket_update_priority"
| "idea_ticket_update_carnet"
| "idea_ticket_link"
| "idea_ticket_unlink"
) {
let raw_ref = arguments let raw_ref = arguments
.get("ref") .get("ref")
.and_then(Value::as_str) .and_then(Value::as_str)

View File

@ -1,11 +1,19 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; 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::{ use domain::{
AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, AgentProfile, AgentRuntime, AssistantContextProvider, ContextInjection, ContextInjectionPlan,
IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, IssueStatus, MarkdownDoc,
PreparedContext, ProfileId, Project, ProjectId, ProjectPath, RemotePath, RuntimeError,
SpawnSpec,
};
use infrastructure::{
FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
}; };
use infrastructure::{FsAssistantContextStore, LocalFileSystem};
use uuid::Uuid; use uuid::Uuid;
struct TempDir(PathBuf); struct TempDir(PathBuf);
@ -65,6 +73,55 @@ fn store(app_data_dir: String) -> FsAssistantContextStore {
FsAssistantContextStore::new(fs, app_data_dir) FsAssistantContextStore::new(fs, app_data_dir)
} }
struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime {
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true)
}
fn prepare_invocation(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
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] #[tokio::test]
async fn default_context_is_embedded_and_injects_the_ticket() { async fn default_context_is_embedded_and_injects_the_ticket() {
let tmp = TempDir::new(); let tmp = TempDir::new();
@ -108,3 +165,83 @@ async fn app_data_override_replaces_the_embedded_default() {
assert!(!body.contains("You are IdeA's ticket editing assistant")); assert!(!body.contains("You are IdeA's ticket editing assistant"));
assert!(body.contains("- Ref: #8")); 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<dyn FileSystem> = 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"
);
}

View File

@ -830,6 +830,129 @@ async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() {
assert_eq!(ticket_tools.mutation_attempts(), 0); assert_eq!(ticket_tools.mutation_attempts(), 0);
} }
#[tokio::test]
async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider() {
for (id, tool, arguments) in [
(
21,
"idea_ticket_update_status",
json!({ "ref": "#8", "expectedVersion": 1, "status": "closed" }),
),
(
22,
"idea_ticket_update_priority",
json!({ "ref": "#8", "expectedVersion": 1, "priority": "high" }),
),
(
23,
"idea_ticket_link",
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
(
24,
"idea_ticket_unlink",
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
] {
let (service, _s) = build_service(FakeContexts::new());
let registry = Arc::new(ToolPolicyRegistry::new());
registry.set(
"assistant-req",
AgentToolPolicy::new(
vec![tool.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(id, tool, arguments);
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, "tool {tool}");
assert!(
error.message.contains("#8"),
"message should name the rejected issue for {tool}; got {}",
error.message
);
assert!(
ticket_tools.calls().is_empty(),
"policy rejection for {tool} must happen before ticket provider dispatch"
);
assert_eq!(
ticket_tools.mutation_attempts(),
0,
"no mutation attempt expected for rejected {tool}"
);
}
}
#[tokio::test]
async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provider() {
for (id, tool, arguments) in [
(
31,
"idea_ticket_update_status",
json!({ "ref": "#7", "expectedVersion": 1, "status": "closed" }),
),
(
32,
"idea_ticket_update_priority",
json!({ "ref": "#7", "expectedVersion": 1, "priority": "high" }),
),
(
33,
"idea_ticket_link",
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
(
34,
"idea_ticket_unlink",
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
),
] {
let (service, _s) = build_service(FakeContexts::new());
let registry = Arc::new(ToolPolicyRegistry::new());
registry.set(
"assistant-req",
AgentToolPolicy::new(
vec![tool.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(id, tool, arguments);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(
response.error.is_none(),
"bound issue should pass policy for {tool}, got {:?}",
response.error
);
let result = response.result.expect("tool result");
assert_eq!(
result["isError"],
json!(true),
"fake provider intentionally returns a tool error after policy passes"
);
assert_eq!(ticket_tools.calls(), vec![tool.to_owned()]);
assert_eq!(
ticket_tools.mutation_attempts(),
1,
"bound {tool} should reach the ticket provider exactly once"
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not // 3. Inter-agent delegation is exposed through MCP; reply protocol is not
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------