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,
|
||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
||||
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
||||
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
|
||||
WindowStateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -76,10 +77,10 @@ use infrastructure::{
|
||||
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime,
|
||||
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider,
|
||||
TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
@ -1355,10 +1356,26 @@ impl AppState {
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
)) 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(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
assistant_context_provider,
|
||||
assistant_environment,
|
||||
Arc::clone(&session_factory),
|
||||
Arc::clone(&structured_sessions),
|
||||
Arc::clone(&tool_policy_store),
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentSessionFactory, SessionPlan};
|
||||
use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer};
|
||||
use domain::AgentProfile;
|
||||
use domain::{
|
||||
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
|
||||
@ -17,6 +17,7 @@ pub struct OpenTicketAssistant {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
contexts: Arc<dyn AssistantContextProvider>,
|
||||
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
|
||||
factory: Arc<dyn AgentSessionFactory>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
@ -52,6 +53,7 @@ impl OpenTicketAssistant {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
contexts: Arc<dyn AssistantContextProvider>,
|
||||
environment: Arc<dyn StructuredSessionEnvironmentPreparer>,
|
||||
factory: Arc<dyn AgentSessionFactory>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
@ -61,6 +63,7 @@ impl OpenTicketAssistant {
|
||||
issues,
|
||||
profiles,
|
||||
contexts,
|
||||
environment,
|
||||
factory,
|
||||
structured,
|
||||
policies,
|
||||
@ -94,39 +97,74 @@ impl OpenTicketAssistant {
|
||||
.prepare_ticket_assistant_context(&input.project, &issue)
|
||||
.await
|
||||
.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
|
||||
.factory
|
||||
.start(
|
||||
&profile,
|
||||
&prepared,
|
||||
&input.project.root,
|
||||
&environment.cwd,
|
||||
&SessionPlan::None,
|
||||
&[],
|
||||
&environment.env,
|
||||
None,
|
||||
)
|
||||
.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 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 old_requester != requester {
|
||||
self.policies.clear_policy(&old_requester);
|
||||
}
|
||||
}
|
||||
let replaced = self.structured.insert_ticket_assistant(
|
||||
input.issue_ref,
|
||||
requester.clone(),
|
||||
Arc::clone(&session),
|
||||
);
|
||||
self.policies.set_policy(requester.clone(), policy);
|
||||
if let Some(old) = replaced {
|
||||
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.
|
||||
pub struct CloseTicketAssistant {
|
||||
structured: Arc<StructuredSessions>,
|
||||
|
||||
@ -8,7 +8,8 @@ use application::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan,
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan,
|
||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
|
||||
};
|
||||
use domain::profile::StructuredAdapter;
|
||||
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 {
|
||||
id: SessionId,
|
||||
shutdowns: Arc<Mutex<usize>>,
|
||||
@ -216,7 +246,15 @@ impl AgentSession for FakeSession {
|
||||
|
||||
#[derive(Default)]
|
||||
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>>,
|
||||
}
|
||||
|
||||
@ -230,15 +268,18 @@ impl AgentSessionFactory for FakeFactory {
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
_env: &[(String, String)],
|
||||
env: &[(String, String)],
|
||||
sandbox: Option<&domain::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((ctx.clone(), session.clone(), sandbox.cloned()));
|
||||
self.starts.lock().unwrap().push((
|
||||
ctx.clone(),
|
||||
cwd.clone(),
|
||||
session.clone(),
|
||||
env.to_vec(),
|
||||
sandbox.cloned(),
|
||||
));
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: SessionId::new_random(),
|
||||
shutdowns: Arc::clone(&self.shutdowns),
|
||||
@ -284,6 +325,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
|
||||
let policies = Arc::new(FakePolicies::default());
|
||||
let events = Arc::new(SpyBus::default());
|
||||
let context = Arc::new(FakeAssistantContext::default());
|
||||
let environment = Arc::new(FakeEnvironmentPreparer::default());
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let open = OpenTicketAssistant::new(
|
||||
Arc::new(FakeIssues {
|
||||
@ -293,6 +335,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
|
||||
profile: profile(profile_id),
|
||||
}),
|
||||
context.clone(),
|
||||
environment.clone(),
|
||||
factory.clone(),
|
||||
structured.clone(),
|
||||
policies.clone(),
|
||||
@ -310,17 +353,42 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
|
||||
.unwrap();
|
||||
|
||||
assert!(structured.session(&output.session_id).is_some());
|
||||
assert_eq!(
|
||||
output.requester,
|
||||
"ticket-assistant:00000000000000000000000000000001:7"
|
||||
);
|
||||
let policy = policies.get_policy(&output.requester).expect("policy set");
|
||||
assert!(policy.permits("idea_ticket_read"));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue.reference()));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update_status", issue.reference()));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update_priority", issue.reference()));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update_carnet", issue.reference()));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_link", issue.reference()));
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_unlink", issue.reference()));
|
||||
assert!(!policy.permits("idea_ticket_create"));
|
||||
assert!(!policy.permits("idea_ticket_list"));
|
||||
assert!(!policy.permits("idea_ask_agent"));
|
||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
|
||||
let prepared = context.prepared.lock().unwrap().clone().unwrap();
|
||||
assert!(prepared.content.as_str().contains("#7"));
|
||||
assert!(prepared.content.as_str().contains("Ticket description"));
|
||||
assert!(prepared.content.as_str().contains("Ticket carnet"));
|
||||
let environment_calls = environment.calls.lock().unwrap();
|
||||
assert_eq!(environment_calls.len(), 1);
|
||||
assert_eq!(environment_calls[0].0, issue.reference());
|
||||
assert_eq!(environment_calls[0].1, output.requester);
|
||||
drop(environment_calls);
|
||||
let starts = factory.starts.lock().unwrap();
|
||||
assert!(matches!(starts[0].1, SessionPlan::None));
|
||||
assert!(starts[0].2.is_none());
|
||||
assert_eq!(starts[0].1.as_str(), "/tmp/app-data/assistant/tickets/1/7");
|
||||
assert!(matches!(starts[0].2, SessionPlan::None));
|
||||
assert_eq!(
|
||||
starts[0].3,
|
||||
vec![(
|
||||
"CODEX_HOME".to_owned(),
|
||||
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned()
|
||||
)]
|
||||
);
|
||||
assert!(starts[0].4.is_none());
|
||||
drop(starts);
|
||||
|
||||
close
|
||||
|
||||
@ -202,6 +202,7 @@ pub use ports::{
|
||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
|
||||
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||
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,
|
||||
};
|
||||
|
||||
@ -128,6 +128,15 @@ pub struct PreparedContext {
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum AssistantContextError {
|
||||
@ -153,6 +162,23 @@ pub trait AssistantContextProvider: Send + Sync {
|
||||
) -> 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.
|
||||
pub trait AgentToolPolicyStore: Send + Sync {
|
||||
/// Sets or replaces the policy for `requester`.
|
||||
|
||||
@ -2,16 +2,26 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::McpRuntime;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::SessionPlan;
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::{
|
||||
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc,
|
||||
PreparedContext, Project, RemotePath,
|
||||
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
||||
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 TICKET_ASSISTANT_FILE: &str = "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.
|
||||
#[derive(Clone)]
|
||||
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 timeparse;
|
||||
|
||||
pub use assistant::FsAssistantContextStore;
|
||||
pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
|
||||
pub use background_task::{
|
||||
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
|
||||
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
|
||||
.get("ref")
|
||||
.and_then(Value::as_str)
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
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::{
|
||||
AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority,
|
||||
IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath,
|
||||
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 infrastructure::{FsAssistantContextStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
@ -65,6 +73,55 @@ fn store(app_data_dir: String) -> FsAssistantContextStore {
|
||||
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]
|
||||
async fn default_context_is_embedded_and_injects_the_ticket() {
|
||||
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("- 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);
|
||||
}
|
||||
|
||||
#[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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user