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:
113
crates/domain/src/agent_tool_policy.rs
Normal file
113
crates/domain/src/agent_tool_policy.rs
Normal file
@ -0,0 +1,113 @@
|
||||
//! MCP tool policy for constrained assistant sessions.
|
||||
//!
|
||||
//! This is a pure domain value. It models what a requester may do through the
|
||||
//! MCP surface; it does not know how the MCP server enforces it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::issue::IssueRef;
|
||||
|
||||
/// Tool policy attached to one agent/requester.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentToolPolicy {
|
||||
/// Explicitly allowed MCP tool names.
|
||||
pub allow: Vec<String>,
|
||||
/// Optional issue boundary for ticket mutations.
|
||||
pub bound_issue: Option<IssueRef>,
|
||||
/// Whether tools outside [`Self::allow`] are denied.
|
||||
pub deny_others: bool,
|
||||
}
|
||||
|
||||
impl AgentToolPolicy {
|
||||
/// Builds a tool policy.
|
||||
#[must_use]
|
||||
pub fn new(allow: Vec<String>, bound_issue: Option<IssueRef>, deny_others: bool) -> Self {
|
||||
Self {
|
||||
allow,
|
||||
bound_issue,
|
||||
deny_others,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when `tool` is allowed by the allowlist/default rule.
|
||||
#[must_use]
|
||||
pub fn permits(&self, tool: &str) -> bool {
|
||||
self.allow.iter().any(|allowed| allowed == tool) || !self.deny_others
|
||||
}
|
||||
|
||||
/// Returns true when `tool` may mutate the supplied ticket reference.
|
||||
///
|
||||
/// The method first applies the tool allowlist. Then, for known ticket mutation
|
||||
/// tools, an optional [`Self::bound_issue`] fences the mutation to that single
|
||||
/// issue reference.
|
||||
#[must_use]
|
||||
pub fn permits_ticket_mutation(&self, tool: &str, issue_ref: IssueRef) -> bool {
|
||||
if !self.permits(tool) {
|
||||
return false;
|
||||
}
|
||||
if !is_ticket_mutation_tool(tool) {
|
||||
return true;
|
||||
}
|
||||
self.bound_issue.map_or(true, |bound| bound == issue_ref)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ticket_mutation_tool(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
"idea_ticket_update"
|
||||
| "idea_ticket_update_status"
|
||||
| "idea_ticket_update_priority"
|
||||
| "idea_ticket_update_carnet"
|
||||
| "idea_ticket_link"
|
||||
| "idea_ticket_unlink"
|
||||
| "idea_ticket_create"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn issue_ref(raw: &str) -> IssueRef {
|
||||
IssueRef::from_str(raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_allowlisted_tool_when_deny_others_is_true() {
|
||||
let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, true);
|
||||
assert!(policy.permits("idea_ticket_read"));
|
||||
assert!(!policy.permits("idea_ask_agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_others_false_allows_non_listed_tools() {
|
||||
let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, false);
|
||||
assert!(policy.permits("idea_ticket_read"));
|
||||
assert!(policy.permits("idea_ask_agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_mutation_is_bound_to_the_configured_issue() {
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec!["idea_ticket_update".to_owned()],
|
||||
Some(issue_ref("#7")),
|
||||
true,
|
||||
);
|
||||
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7")));
|
||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_mutation_requires_the_tool_itself_to_be_allowed() {
|
||||
let policy = AgentToolPolicy::new(
|
||||
vec!["idea_ticket_read".to_owned()],
|
||||
Some(issue_ref("#7")),
|
||||
true,
|
||||
);
|
||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7")));
|
||||
}
|
||||
}
|
||||
@ -183,6 +183,18 @@ pub enum DomainEvent {
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// A ticket assistant session was opened for an issue.
|
||||
TicketAssistantOpened {
|
||||
/// Bound issue.
|
||||
issue_ref: IssueRef,
|
||||
/// Runtime profile used by the assistant.
|
||||
profile_id: ProfileId,
|
||||
},
|
||||
/// A ticket assistant session was closed for an issue.
|
||||
TicketAssistantClosed {
|
||||
/// Bound issue.
|
||||
issue_ref: IssueRef,
|
||||
},
|
||||
/// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on
|
||||
/// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an
|
||||
/// explicit signal). Discrete, low-frequency beacon relayed to the front so the
|
||||
@ -531,6 +543,14 @@ mod tests {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn profile(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn issue_ref(n: u64) -> IssueRef {
|
||||
IssueRef::from(crate::IssueNumber::new(n).unwrap())
|
||||
}
|
||||
|
||||
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
|
||||
|
||||
#[test]
|
||||
@ -638,6 +658,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_assistant_opened_constructs_and_compares() {
|
||||
let ev = DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: issue_ref(7),
|
||||
profile_id: profile(9),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: issue_ref(7),
|
||||
profile_id: profile(9),
|
||||
}
|
||||
);
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantOpened {
|
||||
issue_ref: issue_ref(8),
|
||||
profile_id: profile(9),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_assistant_closed_constructs_and_compares() {
|
||||
let ev = DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: issue_ref(7),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: issue_ref(7),
|
||||
}
|
||||
);
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::TicketAssistantClosed {
|
||||
issue_ref: issue_ref(8),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_session_limit_variants_are_not_equal() {
|
||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod agent_tool_policy;
|
||||
pub mod background_task;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
@ -79,6 +80,8 @@ pub use project::{Project, ProjectPath};
|
||||
|
||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
|
||||
pub use agent_tool_policy::AgentToolPolicy;
|
||||
|
||||
pub use background_task::{
|
||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
||||
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||
@ -180,7 +183,8 @@ pub use orchestrator::{
|
||||
};
|
||||
|
||||
pub use ports::{
|
||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
|
||||
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||
|
||||
@ -28,6 +28,7 @@ use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agent::AgentManifest;
|
||||
use crate::agent_tool_policy::AgentToolPolicy;
|
||||
use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
@ -123,6 +124,43 @@ pub struct PreparedContext {
|
||||
pub project_root: String,
|
||||
}
|
||||
|
||||
/// Errors returned while preparing the IdeA-owned ticket assistant context.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum AssistantContextError {
|
||||
/// The context source was missing and no embedded default could be used.
|
||||
#[error("assistant context not found")]
|
||||
NotFound,
|
||||
/// The context could not be read or rendered.
|
||||
#[error("assistant context store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// Provides the IdeA-owned context used by ticket assistant sessions.
|
||||
#[async_trait]
|
||||
pub trait AssistantContextProvider: Send + Sync {
|
||||
/// Renders a prepared context for a ticket assistant bound to `issue`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AssistantContextError`] when the context cannot be read or rendered.
|
||||
async fn prepare_ticket_assistant_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
issue: &Issue,
|
||||
) -> Result<PreparedContext, AssistantContextError>;
|
||||
}
|
||||
|
||||
/// Stores requester-scoped MCP tool policies for ephemeral assistant sessions.
|
||||
pub trait AgentToolPolicyStore: Send + Sync {
|
||||
/// Sets or replaces the policy for `requester`.
|
||||
fn set_policy(&self, requester: String, policy: AgentToolPolicy);
|
||||
|
||||
/// Returns the policy for `requester`, if any.
|
||||
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy>;
|
||||
|
||||
/// Clears the policy for `requester`.
|
||||
fn clear_policy(&self, requester: &str);
|
||||
}
|
||||
|
||||
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
||||
/// on-disk transcript format (CONTEXT §T6).
|
||||
///
|
||||
|
||||
@ -145,6 +145,31 @@ pub struct SandboxPlan {
|
||||
pub default_posture: Posture,
|
||||
}
|
||||
|
||||
impl SandboxPlan {
|
||||
/// Builds the ticket-assistant preset: read-only access to the project root,
|
||||
/// denied writes by default, while keeping the assistant run directory
|
||||
/// writable so CLI session metadata/resume files can still function.
|
||||
#[must_use]
|
||||
pub fn project_read_only(project_root: impl Into<String>, run_dir: impl Into<String>) -> Self {
|
||||
let project_root = project_root.into();
|
||||
let run_dir = run_dir.into();
|
||||
let mut allowed = vec![PathGrant {
|
||||
abs_root: project_root,
|
||||
access: PathAccess::RO,
|
||||
}];
|
||||
if !run_dir.trim().is_empty() {
|
||||
allowed.push(PathGrant {
|
||||
abs_root: run_dir,
|
||||
access: PathAccess::RO | PathAccess::RW,
|
||||
});
|
||||
}
|
||||
Self {
|
||||
allowed,
|
||||
default_posture: Posture::Deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots
|
||||
/// it emits. Borrowed: a plan is computed at the launch site, never stored.
|
||||
pub struct SandboxContext<'a> {
|
||||
@ -465,6 +490,19 @@ mod tests {
|
||||
assert!(!g.access.contains(PathAccess::RW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_read_only_preset_allows_project_read_and_run_dir_write_only() {
|
||||
let plan = SandboxPlan::project_read_only(ROOT, "/home/anthony/.ideai/run/assistant");
|
||||
let project = grant(&plan, ROOT).expect("project root granted");
|
||||
assert_eq!(project.access, PathAccess::RO);
|
||||
assert!(!project.access.contains(PathAccess::RW));
|
||||
|
||||
let run = grant(&plan, "/home/anthony/.ideai/run/assistant").expect("run dir granted");
|
||||
assert!(run.access.contains(PathAccess::RO));
|
||||
assert!(run.access.contains(PathAccess::RW));
|
||||
assert_eq!(plan.default_posture, Posture::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_delete_map_to_rw() {
|
||||
let e = eff(
|
||||
|
||||
Reference in New Issue
Block a user