feat(tickets): création d'un assistant IA de ticket (backend + frontend)
Ajoute le chat assistant IA attaché à un ticket (#8). Backend Rust : - use cases OpenTicketAssistant/CloseTicketAssistant + tests - politique d'outils par agent (domain/agent_tool_policy) et policy MCP - store de contexte assistant + gabarit default_ticket_assistant.md + tests - events TicketAssistantOpened/Closed - commandes Tauri open_ticket_chat/close_ticket_chat et câblage state/lib/events Frontend : - gateway (ports, adapters ticket + mock, domain) - hook useTicketAssistant + composant TicketAssistantPanel - intégration dans TicketDetail Tests verts : vitest tickets.test.tsx (23), cargo test application::ticket_assistant (1), infrastructure::assistant_context_store (2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,5 @@
|
||||
# Ticket Assistant
|
||||
|
||||
You are IdeA's ticket editing assistant. Help improve the bound ticket only.
|
||||
Read the project context when needed, propose concise changes, and use ticket
|
||||
tools only for the ticket explicitly injected below.
|
||||
76
crates/infrastructure/src/assistant/mod.rs
Normal file
76
crates/infrastructure/src/assistant/mod.rs
Normal file
@ -0,0 +1,76 @@
|
||||
//! Ticket assistant infrastructure adapters.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc,
|
||||
PreparedContext, Project, RemotePath,
|
||||
};
|
||||
|
||||
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");
|
||||
|
||||
/// File-backed provider for the IdeA-owned ticket assistant context.
|
||||
#[derive(Clone)]
|
||||
pub struct FsAssistantContextStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsAssistantContextStore {
|
||||
/// Builds the store from an injected filesystem and app-data directory.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&self, rel: &str) -> String {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
|
||||
fn context_path(&self) -> RemotePath {
|
||||
RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}")))
|
||||
}
|
||||
|
||||
async fn read_base_context(&self) -> Result<String, AssistantContextError> {
|
||||
let path = self.context_path();
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => {
|
||||
String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string()))
|
||||
}
|
||||
Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()),
|
||||
Err(e) => Err(AssistantContextError::Store(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AssistantContextProvider for FsAssistantContextStore {
|
||||
async fn prepare_ticket_assistant_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
issue: &Issue,
|
||||
) -> Result<PreparedContext, AssistantContextError> {
|
||||
let mut content = self.read_base_context().await?;
|
||||
content.push_str("\n\n## Bound Ticket\n\n");
|
||||
content.push_str(&format!("- Ref: {}\n", issue.reference()));
|
||||
content.push_str(&format!("- Title: {}\n", issue.title));
|
||||
content.push_str("\n### Description\n\n");
|
||||
content.push_str(issue.description.as_str());
|
||||
content.push_str("\n\n### Carnet\n\n");
|
||||
content.push_str(issue.carnet.as_str());
|
||||
content.push('\n');
|
||||
|
||||
Ok(PreparedContext {
|
||||
content: MarkdownDoc::new(content),
|
||||
relative_path: TICKET_ASSISTANT_FILE.to_owned(),
|
||||
project_root: project.root.as_str().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod assistant;
|
||||
pub mod background_task;
|
||||
pub mod clock;
|
||||
pub mod conversation;
|
||||
@ -39,6 +40,7 @@ pub mod sprints;
|
||||
pub mod store;
|
||||
pub mod timeparse;
|
||||
|
||||
pub use assistant::FsAssistantContextStore;
|
||||
pub use background_task::{
|
||||
bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink,
|
||||
BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome,
|
||||
@ -64,6 +66,7 @@ pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
ToolPolicyRegistry,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
//! child process).
|
||||
|
||||
pub mod jsonrpc;
|
||||
pub mod policy;
|
||||
pub mod server;
|
||||
pub mod tickets;
|
||||
pub mod tools;
|
||||
@ -37,6 +38,7 @@ pub mod transport;
|
||||
pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use policy::ToolPolicyRegistry;
|
||||
pub use server::McpServer;
|
||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||
|
||||
76
crates/infrastructure/src/orchestrator/mcp/policy.rs
Normal file
76
crates/infrastructure/src/orchestrator/mcp/policy.rs
Normal file
@ -0,0 +1,76 @@
|
||||
//! In-memory MCP tool policy registry.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use domain::AgentToolPolicy;
|
||||
use domain::AgentToolPolicyStore;
|
||||
|
||||
/// Stores per-requester MCP tool policies for live assistant sessions.
|
||||
#[derive(Default)]
|
||||
pub struct ToolPolicyRegistry {
|
||||
policies: RwLock<HashMap<String, AgentToolPolicy>>,
|
||||
}
|
||||
|
||||
impl ToolPolicyRegistry {
|
||||
/// Builds an empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets or replaces the policy for `requester`.
|
||||
pub fn set(&self, requester: impl Into<String>, policy: AgentToolPolicy) {
|
||||
self.policies
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(requester.into(), policy);
|
||||
}
|
||||
|
||||
/// Returns the policy for `requester`, if any.
|
||||
#[must_use]
|
||||
pub fn get(&self, requester: &str) -> Option<AgentToolPolicy> {
|
||||
self.policies.read().unwrap().get(requester).cloned()
|
||||
}
|
||||
|
||||
/// Clears the policy for `requester`.
|
||||
pub fn clear(&self, requester: &str) {
|
||||
self.policies.write().unwrap().remove(requester);
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentToolPolicyStore for ToolPolicyRegistry {
|
||||
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
|
||||
self.set(requester, policy);
|
||||
}
|
||||
|
||||
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
|
||||
self.get(requester)
|
||||
}
|
||||
|
||||
fn clear_policy(&self, requester: &str) {
|
||||
self.clear(requester);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use domain::IssueRef;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn set_get_clear_roundtrips_by_requester() {
|
||||
let registry = ToolPolicyRegistry::new();
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
Some("#7".parse::<IssueRef>().unwrap()),
|
||||
true,
|
||||
);
|
||||
registry.set("assistant-1", policy.clone());
|
||||
assert_eq!(registry.get("assistant-1"), Some(policy));
|
||||
assert_eq!(registry.get("assistant-2"), None);
|
||||
registry.clear("assistant-1");
|
||||
assert_eq!(registry.get("assistant-1"), None);
|
||||
}
|
||||
}
|
||||
@ -16,11 +16,12 @@
|
||||
//! at the composition root (M3), exactly like the watcher — no application logic is
|
||||
//! duplicated here.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use application::OrchestratorService;
|
||||
use domain::{DomainEvent, OrchestrationSource, Project};
|
||||
use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@ -28,6 +29,7 @@ use super::jsonrpc::{
|
||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||
JSONRPC_VERSION,
|
||||
};
|
||||
use super::policy::ToolPolicyRegistry;
|
||||
use super::tickets::TicketToolProvider;
|
||||
use super::tools::{self, ToolMapError};
|
||||
|
||||
@ -65,6 +67,8 @@ pub struct McpServer {
|
||||
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||
/// provider maps those calls to application/domain `Issue` use cases.
|
||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@ -80,6 +84,7 @@ impl McpServer {
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ticket_tools: None,
|
||||
tool_policies: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +116,13 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the requester-scoped MCP tool policy registry.
|
||||
#[must_use]
|
||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||
self.tool_policies = Some(tool_policies);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a per-connection clone of this server tagged with the connected
|
||||
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||
///
|
||||
@ -128,6 +140,7 @@ impl McpServer {
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ticket_tools: self.ticket_tools.clone(),
|
||||
tool_policies: self.tool_policies.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,8 +313,17 @@ impl McpServer {
|
||||
|
||||
/// The `tools/list` result: the catalogue as MCP tool descriptors.
|
||||
fn tools_list_result(&self) -> Value {
|
||||
let policy = self
|
||||
.tool_policies
|
||||
.as_ref()
|
||||
.and_then(|registry| registry.get(&self.requester));
|
||||
let tools: Vec<Value> = tools::catalogue()
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
policy
|
||||
.as_ref()
|
||||
.map_or(true, |policy| policy.permits(t.name))
|
||||
})
|
||||
.map(|t| {
|
||||
json!({
|
||||
"name": t.name,
|
||||
@ -324,6 +346,14 @@ impl McpServer {
|
||||
.to_owned();
|
||||
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
|
||||
if let Some(policy) = self
|
||||
.tool_policies
|
||||
.as_ref()
|
||||
.and_then(|registry| registry.get(&self.requester))
|
||||
{
|
||||
self.enforce_tool_policy(&policy, &name, &arguments)?;
|
||||
}
|
||||
|
||||
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
|
||||
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
|
||||
let started = Instant::now();
|
||||
@ -437,6 +467,47 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_tool_policy(
|
||||
&self,
|
||||
policy: &AgentToolPolicy,
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
) -> Result<(), JsonRpcError> {
|
||||
if !policy.permits(name) {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!(
|
||||
"tool `{name}` is not permitted for requester {}",
|
||||
self.requester
|
||||
),
|
||||
));
|
||||
}
|
||||
if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") {
|
||||
let raw_ref = arguments
|
||||
.get("ref")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `{name}` requires a ticket ref under the active policy"),
|
||||
)
|
||||
})?;
|
||||
let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("invalid ticket ref: {e}"),
|
||||
)
|
||||
})?;
|
||||
if !policy.permits_ticket_mutation(name, issue_ref) {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `{name}` is not permitted for ticket {issue_ref}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
|
||||
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
|
||||
/// connected peer's real agent id (carried in the loopback handshake, cadrage v5
|
||||
|
||||
110
crates/infrastructure/tests/assistant_context_store.rs
Normal file
110
crates/infrastructure/tests/assistant_context_store.rs
Normal file
@ -0,0 +1,110 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority,
|
||||
IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath,
|
||||
};
|
||||
use infrastructure::{FsAssistantContextStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("idea-assistant-context-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.join("app-data").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn project_root(&self) -> ProjectPath {
|
||||
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn project(root: ProjectPath) -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
"demo",
|
||||
root,
|
||||
domain::remote::RemoteRef::local(),
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn issue(number: u64) -> Issue {
|
||||
Issue::new(
|
||||
IssueId::from_uuid(Uuid::from_u128(number as u128)),
|
||||
IssueNumber::new(number).unwrap(),
|
||||
"Clarify onboarding",
|
||||
MarkdownDoc::new("Initial description body"),
|
||||
IssueStatus::Open,
|
||||
IssuePriority::High,
|
||||
MarkdownDoc::new("Carnet notes"),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
IssueActor::User,
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn store(app_data_dir: String) -> FsAssistantContextStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsAssistantContextStore::new(fs, app_data_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_context_is_embedded_and_injects_the_ticket() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(tmp.project_root());
|
||||
let store = store(tmp.app_data_dir());
|
||||
|
||||
let ctx = store
|
||||
.prepare_ticket_assistant_context(&project, &issue(7))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = ctx.content.as_str();
|
||||
assert!(body.contains("# Ticket Assistant"));
|
||||
assert!(body.contains("- Ref: #7"));
|
||||
assert!(body.contains("- Title: Clarify onboarding"));
|
||||
assert!(body.contains("Initial description body"));
|
||||
assert!(body.contains("Carnet notes"));
|
||||
assert_eq!(ctx.project_root, project.root.as_str());
|
||||
assert_eq!(ctx.relative_path, "ticket-assistant.md");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_data_override_replaces_the_embedded_default() {
|
||||
let tmp = TempDir::new();
|
||||
let app_data_dir = tmp.app_data_dir();
|
||||
let override_path = PathBuf::from(&app_data_dir)
|
||||
.join("assistant")
|
||||
.join("ticket-assistant.md");
|
||||
std::fs::create_dir_all(override_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&override_path, "# Custom assistant").unwrap();
|
||||
|
||||
let project = project(tmp.project_root());
|
||||
let store = store(app_data_dir);
|
||||
let ctx = store
|
||||
.prepare_ticket_assistant_context(&project, &issue(8))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = ctx.content.as_str();
|
||||
assert!(body.starts_with("# Custom assistant"));
|
||||
assert!(!body.contains("You are IdeA's ticket editing assistant"));
|
||||
assert!(body.contains("- Ref: #8"));
|
||||
}
|
||||
@ -21,6 +21,7 @@
|
||||
//! `OrchestratorRequest::validate` (typed error, **no** dispatch).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -44,7 +45,7 @@ use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::terminal::{SessionKind, TerminalSession};
|
||||
use domain::{PtySize, SessionId};
|
||||
use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
@ -55,7 +56,7 @@ use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock,
|
||||
SystemMillisClock, ToolPolicyRegistry,
|
||||
};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
@ -591,6 +592,73 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_is_filtered_for_requester_with_tool_policy() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec![
|
||||
"idea_ticket_read".to_owned(),
|
||||
"idea_ticket_update".to_owned(),
|
||||
"idea_ticket_update_carnet".to_owned(),
|
||||
],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
|
||||
}))
|
||||
.unwrap();
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "got error: {:?}", response.error);
|
||||
let result = response.result.expect("result");
|
||||
let tools = result["tools"].as_array().expect("tools array");
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"idea_ticket_read",
|
||||
"idea_ticket_update",
|
||||
"idea_ticket_update_carnet"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requester_without_tool_policy_keeps_the_full_tools_list() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
let server = server(service)
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("ordinary-agent");
|
||||
|
||||
let raw = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
|
||||
}))
|
||||
.unwrap();
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "got error: {:?}", response.error);
|
||||
let result = response.result.expect("result");
|
||||
let names: Vec<&str> = result["tools"]
|
||||
.as_array()
|
||||
.expect("tools array")
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&"idea_ask_agent"));
|
||||
assert!(names.contains(&"idea_ticket_update"));
|
||||
assert!(names.len() > 3, "unfiltered requester got {names:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -685,6 +753,82 @@ async fn update_context_and_create_skill_calls_succeed() {
|
||||
assert_eq!(r.result.expect("result")["isError"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_outside_allowlist_is_rejected_for_policy_requester() {
|
||||
let contexts = FakeContexts::new();
|
||||
contexts.seed_agent("architect");
|
||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
Some(IssueRef::from_str("#7").unwrap()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_tool_policies(registry)
|
||||
.for_requester("assistant-req");
|
||||
|
||||
let raw = tools_call(
|
||||
11,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "please handle this" }),
|
||||
);
|
||||
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);
|
||||
assert!(
|
||||
error.message.contains("not permitted"),
|
||||
"got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(response.result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let registry = Arc::new(ToolPolicyRegistry::new());
|
||||
registry.set(
|
||||
"assistant-req",
|
||||
AgentToolPolicy::new(
|
||||
vec!["idea_ticket_update".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(
|
||||
12,
|
||||
"idea_ticket_update",
|
||||
json!({
|
||||
"ref": "#8",
|
||||
"expectedVersion": 1,
|
||||
"title": "must not pass"
|
||||
}),
|
||||
);
|
||||
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);
|
||||
assert!(
|
||||
error.message.contains("#8"),
|
||||
"message should name the rejected issue; got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(
|
||||
ticket_tools.calls().is_empty(),
|
||||
"policy rejection must happen before ticket provider dispatch"
|
||||
);
|
||||
assert_eq!(ticket_tools.mutation_attempts(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user