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:
@ -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),
|
||||||
|
|||||||
@ -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>,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user