merge(templates): nom d'agent par défaut, permissions système/MCP et correctif modèle par défaut — #242 (QA verte)

This commit is contained in:
2026-08-23 11:00:20 +02:00
24 changed files with 1360 additions and 221 deletions

View File

@ -24,16 +24,16 @@ use application::{
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReadMemoryIndexInput, ReadProjectContextInput, ReadTemplateInput, RecallMemoryInput,
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput,
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect, RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateTemplateInput,
}; };
use backend::stream::OutputSink; use backend::stream::OutputSink;
use domain::ports::ModelServerRuntime; use domain::ports::ModelServerRuntime;
@ -91,7 +91,7 @@ use crate::embedded_server::{
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::{AppState, FocusedProjectDto}; use crate::state::{AppState, FocusedProjectDto};
use crate::stream::TauriChannelSink; use crate::stream::TauriChannelSink;
use domain::{DeviceId, SkillRef, SkillScope}; use domain::{AgentOrigin, DeviceId, SkillRef, SkillScope};
use uuid::Uuid; use uuid::Uuid;
/// `health` — trivial command validating the full IPC pipeline /// `health` — trivial command validating the full IPC pipeline
@ -492,12 +492,83 @@ pub(crate) async fn open_project_for_adapter(
// live-state : les Running sans handle vivant deviennent terminales, les // live-state : les Running sans handle vivant deviennent terminales, les
// complétions WakeOwner non livrées repassent dans l'inbox unifiée. // complétions WakeOwner non livrées repassent dans l'inbox unifiée.
let _ = state.reconcile_background_tasks.execute(id).await; let _ = state.reconcile_background_tasks.execute(id).await;
let _ = backfill_agent_templates_from_project(&output.project, state).await;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3). // (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project); state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await; state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output)) Ok(ProjectDto::from(output))
} }
async fn backfill_agent_templates_from_project(
project: &domain::Project,
state: &AppState,
) -> Result<(), AppError> {
let agents = state
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?
.agents;
let system_permissions = state
.get_project_system_permissions
.execute(GetProjectSystemPermissionsInput {
project: project.clone(),
})
.await?
.permissions;
let mcp_tool_permissions = state
.read_mcp_tool_permissions
.execute(ReadMcpToolPermissionsInput {
project: project.clone(),
})
.await?
.permissions;
for agent in agents {
let AgentOrigin::FromTemplate { template_id, .. } = agent.origin else {
continue;
};
let template = match state
.read_template
.execute(ReadTemplateInput { template_id })
.await
{
Ok(out) => out.template,
Err(err) if err.code() == "NOT_FOUND" => continue,
Err(err) => return Err(err),
};
let default_system_permissions = system_permissions.agent_permissions(agent.id).cloned();
let default_mcp_tool_policy = mcp_tool_permissions
.agents
.iter()
.find(|entry| entry.agent_id == agent.id)
.map(|entry| entry.policy.clone());
if template.default_agent_name.as_deref() == Some(agent.name.as_str())
&& template.default_profile_id == agent.profile_id
&& template.default_system_permissions == default_system_permissions
&& template.default_mcp_tool_policy == default_mcp_tool_policy
{
continue;
}
state
.update_template
.execute(UpdateTemplateInput {
template_id,
content: None,
name: None,
default_profile_id: Some(agent.profile_id),
default_agent_name: Some(Some(agent.name)),
default_system_permissions: Some(default_system_permissions),
default_mcp_tool_policy: Some(default_mcp_tool_policy),
})
.await?;
}
Ok(())
}
/// `close_project` — persist state and release resources for a project. /// `close_project` — persist state and release resources for a project.
/// ///
/// # Errors /// # Errors

View File

@ -7,14 +7,16 @@ use std::sync::Arc;
use application::{ use application::{
CreateTemplate, CreateTemplateInput, DeleteTemplate, DeleteTemplateInput, ListTemplates, CreateTemplate, CreateTemplateInput, DeleteTemplate, DeleteTemplateInput, ListTemplates,
ReadTemplate, ReadTemplateInput, UpdateTemplate, UpdateTemplateInput, ReadTemplate, ReadTemplateInput, UpdateTemplate,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::{Project, TemplateId}; use domain::{Project, TemplateId};
use infrastructure::{TemplateToolError, TemplateToolProvider}; use infrastructure::{TemplateToolError, TemplateToolProvider};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::dto::{parse_profile_id, parse_template_id, TemplateDto, TemplateListDto}; use crate::dto::{
parse_profile_id, parse_template_id, TemplateDto, TemplateListDto, UpdateTemplateRequestDto,
};
/// Concrete provider for the public MCP template tools. /// Concrete provider for the public MCP template tools.
#[derive(Clone)] #[derive(Clone)]
@ -65,18 +67,30 @@ impl TemplateToolProvider for AppTemplateToolProvider {
"defaultProfileId", "defaultProfileId",
)?) )?)
.map_err(dto_tool_error)?, .map_err(dto_tool_error)?,
default_agent_name: optional_string(&arguments, "defaultAgentName")?
.map(str::to_owned),
default_system_permissions: optional_json_value(
&arguments,
"defaultSystemPermissions",
)?,
default_mcp_tool_policy: optional_json_value_alias(
&arguments,
"defaultMcpToolPermissions",
"defaultMcpToolPolicy",
)?,
}) })
.await .await
.map_err(template_error)?; .map_err(template_error)?;
json!(TemplateDto::from(template)) json!(TemplateDto::from(template))
} }
"idea_template_update" => { "idea_template_update" => {
let request: UpdateTemplateRequestDto =
serde_json::from_value(arguments).map_err(|err| {
TemplateToolError::new("invalid", format!("invalid update payload: {err}"))
})?;
let template = self let template = self
.update .update
.execute(UpdateTemplateInput { .execute(request.into_input().map_err(dto_tool_error)?)
template_id: parse_json_template_id(&arguments)?,
content: required_str(&arguments, "content")?.to_owned(),
})
.await .await
.map_err(template_error)?; .map_err(template_error)?;
json!(TemplateDto::from(template)) json!(TemplateDto::from(template))
@ -133,6 +147,51 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Template
.ok_or_else(|| TemplateToolError::new("invalid", format!("missing {key}"))) .ok_or_else(|| TemplateToolError::new("invalid", format!("missing {key}")))
} }
fn optional_string<'a>(
arguments: &'a Value,
key: &str,
) -> Result<Option<&'a str>, TemplateToolError> {
match arguments.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_str()
.map(Some)
.ok_or_else(|| TemplateToolError::new("invalid", format!("{key} must be a string"))),
}
}
fn optional_json_value<T>(arguments: &Value, key: &str) -> Result<Option<T>, TemplateToolError>
where
T: serde::de::DeserializeOwned,
{
match arguments.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(|err| TemplateToolError::new("invalid", format!("invalid {key}: {err}"))),
}
}
fn optional_json_value_alias<T>(
arguments: &Value,
primary_key: &str,
legacy_key: &str,
) -> Result<Option<T>, TemplateToolError>
where
T: serde::de::DeserializeOwned,
{
let primary = optional_json_value(arguments, primary_key)?;
let legacy = optional_json_value(arguments, legacy_key)?;
match (primary, legacy) {
(Some(value), None) | (None, Some(value)) => Ok(Some(value)),
(None, None) => Ok(None),
(Some(_), Some(_)) => Err(TemplateToolError::new(
"invalid",
format!("{primary_key} and {legacy_key} cannot both be provided"),
)),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
@ -340,7 +399,7 @@ mod tests {
} }
#[test] #[test]
fn template_provider_preserves_content_only_b4_boundary() { fn template_provider_metadata_updates_preserve_version_without_content_change() {
let template = AgentTemplate::new( let template = AgentTemplate::new(
TemplateId::from_uuid(Uuid::from_u128(7)), TemplateId::from_uuid(Uuid::from_u128(7)),
"Base", "Base",
@ -348,10 +407,22 @@ mod tests {
ProfileId::from_uuid(Uuid::from_u128(9)), ProfileId::from_uuid(Uuid::from_u128(9)),
) )
.unwrap(); .unwrap();
let updated = template.with_updated_content(MarkdownDoc::new("v2")); let updated = template
.with_metadata(
"Renamed".to_owned(),
ProfileId::from_uuid(Uuid::from_u128(10)),
Some("Agent".to_owned()),
None,
None,
)
.unwrap();
assert_eq!(updated.name, "Base"); assert_eq!(updated.name, "Renamed");
assert_eq!(updated.default_profile_id, template.default_profile_id); assert_eq!(
assert_eq!(updated.version, TemplateVersion(2)); updated.default_profile_id,
ProfileId::from_uuid(Uuid::from_u128(10))
);
assert_eq!(updated.default_agent_name.as_deref(), Some("Agent"));
assert_eq!(updated.version, TemplateVersion::INITIAL);
} }
} }

View File

@ -15,6 +15,7 @@ use application::{
use domain::ids::{AgentId, ProfileId, TemplateId}; use domain::ids::{AgentId, ProfileId, TemplateId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::template::{AgentTemplate, TemplateVersion}; use domain::template::{AgentTemplate, TemplateVersion};
use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -52,6 +53,10 @@ fn template_dto_serialises_camelcase() {
v["defaultProfileId"], v["defaultProfileId"],
ProfileId::from_uuid(Uuid::from_u128(2)).to_string() ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
); );
assert!(v.get("defaultAgentName").is_none());
assert!(v.get("defaultSystemPermissions").is_none());
assert!(v.get("defaultMcpToolPermissions").is_none());
assert!(v.get("defaultMcpToolPolicy").is_none());
// no snake_case leak // no snake_case leak
assert!( assert!(
v.get("content_md").is_none(), v.get("content_md").is_none(),
@ -220,6 +225,34 @@ fn create_template_request_deserialises_camelcase() {
assert_eq!(dto.name, "Backend Template"); assert_eq!(dto.name, "Backend Template");
assert_eq!(dto.content, "# My context"); assert_eq!(dto.content, "# My context");
assert_eq!(dto.default_profile_id, profile_id); assert_eq!(dto.default_profile_id, profile_id);
assert!(dto.default_agent_name.is_none());
assert!(dto.default_system_permissions.is_none());
assert!(dto.default_mcp_tool_policy.is_none());
}
#[test]
fn create_template_request_deserialises_template_defaults() {
let profile_id = Uuid::from_u128(21).to_string();
let raw = json!({
"name": "Backend Template",
"content": "# My context",
"defaultProfileId": profile_id,
"defaultAgentName": "Backend",
"defaultSystemPermissions": { "network": "allow" },
"defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] }
});
let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.default_agent_name.as_deref(), Some("Backend"));
assert_eq!(
dto.default_system_permissions,
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow)))
);
assert_eq!(
dto.default_mcp_tool_policy,
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()]
})
);
} }
#[test] #[test]
@ -231,7 +264,31 @@ fn update_template_request_deserialises_camelcase() {
}); });
let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap(); let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.template_id, template_id); assert_eq!(dto.template_id, template_id);
assert_eq!(dto.content, "# Updated content"); assert_eq!(dto.content.as_deref(), Some("# Updated content"));
assert!(dto.default_profile_id.is_none());
}
#[test]
fn update_template_request_deserialises_metadata_and_nullable_clears() {
let template_id = Uuid::from_u128(31).to_string();
let profile_id = Uuid::from_u128(32).to_string();
let raw = json!({
"templateId": template_id,
"name": "Renamed",
"defaultProfileId": profile_id,
"defaultAgentName": null,
"defaultSystemPermissions": { "network": "deny" },
"defaultMcpToolPermissions": null
});
let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.name.as_deref(), Some("Renamed"));
assert_eq!(dto.default_profile_id.as_deref(), Some(profile_id.as_str()));
assert_eq!(dto.default_agent_name, Some(None));
assert_eq!(
dto.default_system_permissions,
Some(Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny))))
);
assert_eq!(dto.default_mcp_tool_policy, Some(None));
} }
#[test] #[test]

View File

@ -13,10 +13,14 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{AgentContextStore, EventBus, IdGenerator, StoreError, TemplateStore}; use domain::ports::{
AgentContextStore, EventBus, IdGenerator, McpToolPermissionStore, StoreError,
SystemPermissionStore, TemplateStore,
};
use domain::{ use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry, Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry,
MarkdownDoc, ProfileId, Project, TemplateId, TemplateVersion, MarkdownDoc, McpToolPolicy, ProfileId, Project, SystemPermissionSet, TemplateId,
TemplateVersion,
}; };
use crate::agent::unique_md_path; use crate::agent::unique_md_path;
@ -35,6 +39,12 @@ pub struct CreateTemplateInput {
pub content: String, pub content: String,
/// Default runtime profile for agents created from this template. /// Default runtime profile for agents created from this template.
pub default_profile_id: ProfileId, pub default_profile_id: ProfileId,
/// Optional default agent name for agents created from this template.
pub default_agent_name: Option<String>,
/// Optional agent-level system permission override applied at creation time.
pub default_system_permissions: Option<SystemPermissionSet>,
/// Optional agent-level MCP tool permission override applied at creation time.
pub default_mcp_tool_policy: Option<McpToolPolicy>,
} }
/// Output of [`CreateTemplate::execute`]. /// Output of [`CreateTemplate::execute`].
@ -69,10 +79,19 @@ impl CreateTemplate {
let id = TemplateId::from_uuid(self.ids.new_uuid()); let id = TemplateId::from_uuid(self.ids.new_uuid());
let template = AgentTemplate::new( let template = AgentTemplate::new(
id, id,
input.name, input.name.clone(),
MarkdownDoc::new(input.content), MarkdownDoc::new(input.content),
input.default_profile_id, input.default_profile_id,
) )
.and_then(|template| {
template.with_metadata(
input.name,
input.default_profile_id,
input.default_agent_name,
input.default_system_permissions,
input.default_mcp_tool_policy,
)
})
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.templates.save(&template).await?; self.templates.save(&template).await?;
Ok(CreateTemplateOutput { template }) Ok(CreateTemplateOutput { template })
@ -88,8 +107,21 @@ impl CreateTemplate {
pub struct UpdateTemplateInput { pub struct UpdateTemplateInput {
/// Template to update. /// Template to update.
pub template_id: TemplateId, pub template_id: TemplateId,
/// New Markdown content. /// New Markdown content. `None` leaves content and version untouched.
pub content: String, pub content: Option<String>,
/// New display name. `None` leaves the current value.
pub name: Option<String>,
/// New default runtime profile. `None` leaves the current value.
pub default_profile_id: Option<ProfileId>,
/// New default agent name. Outer `None` leaves the current value; inner
/// `None` clears the default so agent creation falls back to template name.
pub default_agent_name: Option<Option<String>>,
/// New default system permissions. Outer `None` leaves the current value;
/// inner `None` preserves project inheritance for future agents.
pub default_system_permissions: Option<Option<SystemPermissionSet>>,
/// New default MCP tool policy. Outer `None` leaves the current value; inner
/// `None` preserves project inheritance for future agents.
pub default_mcp_tool_policy: Option<Option<McpToolPolicy>>,
} }
/// Output of [`UpdateTemplate::execute`]. /// Output of [`UpdateTemplate::execute`].
@ -123,7 +155,28 @@ impl UpdateTemplate {
input: UpdateTemplateInput, input: UpdateTemplateInput,
) -> Result<UpdateTemplateOutput, AppError> { ) -> Result<UpdateTemplateOutput, AppError> {
let current = self.templates.get(input.template_id).await?; let current = self.templates.get(input.template_id).await?;
let updated = current.with_updated_content(MarkdownDoc::new(input.content)); let mut updated = if let Some(content) = input.content {
current.with_updated_content(MarkdownDoc::new(content))
} else {
current
};
updated = updated
.with_metadata(
input.name.unwrap_or_else(|| updated.name.clone()),
input
.default_profile_id
.unwrap_or(updated.default_profile_id),
input
.default_agent_name
.unwrap_or_else(|| updated.default_agent_name.clone()),
input
.default_system_permissions
.unwrap_or_else(|| updated.default_system_permissions.clone()),
input
.default_mcp_tool_policy
.unwrap_or_else(|| updated.default_mcp_tool_policy.clone()),
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.templates.save(&updated).await?; self.templates.save(&updated).await?;
self.events.publish(DomainEvent::TemplateUpdated { self.events.publish(DomainEvent::TemplateUpdated {
template_id: updated.id, template_id: updated.id,
@ -271,6 +324,8 @@ pub struct CreateAgentFromTemplate {
contexts: Arc<dyn AgentContextStore>, contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn IdGenerator>, ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>, events: Arc<dyn EventBus>,
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
mcp_tool_permissions: Option<Arc<dyn McpToolPermissionStore>>,
} }
impl CreateAgentFromTemplate { impl CreateAgentFromTemplate {
@ -287,9 +342,30 @@ impl CreateAgentFromTemplate {
contexts, contexts,
ids, ids,
events, events,
system_permissions: None,
mcp_tool_permissions: None,
} }
} }
/// Injects the project system permission store used to project template
/// agent overrides at creation time.
#[must_use]
pub fn with_system_permission_store(mut self, store: Arc<dyn SystemPermissionStore>) -> Self {
self.system_permissions = Some(store);
self
}
/// Injects the project MCP tool permission store used to project template
/// agent overrides at creation time.
#[must_use]
pub fn with_mcp_tool_permission_store(
mut self,
store: Arc<dyn McpToolPermissionStore>,
) -> Self {
self.mcp_tool_permissions = Some(store);
self
}
/// Executes creation from a template. /// Executes creation from a template.
/// ///
/// # Errors /// # Errors
@ -303,7 +379,10 @@ impl CreateAgentFromTemplate {
let template = self.templates.get(input.template_id).await?; let template = self.templates.get(input.template_id).await?;
let manifest = self.contexts.load_manifest(&input.project).await?; let manifest = self.contexts.load_manifest(&input.project).await?;
let name = input.name.unwrap_or_else(|| template.name.clone()); let name = input
.name
.or_else(|| template.default_agent_name.clone())
.unwrap_or_else(|| template.name.clone());
let id = AgentId::from_uuid(self.ids.new_uuid()); let id = AgentId::from_uuid(self.ids.new_uuid());
let md_path = unique_md_path(&name, &manifest); let md_path = unique_md_path(&name, &manifest);
let origin = AgentOrigin::FromTemplate { let origin = AgentOrigin::FromTemplate {
@ -333,6 +412,27 @@ impl CreateAgentFromTemplate {
.write_context(&input.project, &agent.id, &template.content_md) .write_context(&input.project, &agent.id, &template.content_md)
.await?; .await?;
if let Some(permissions) = template.default_system_permissions.clone() {
if let Some(store) = &self.system_permissions {
let mut doc = store.load_system_permissions(&input.project).await?;
doc.set_agent_permissions(agent.id, Some(permissions));
store.save_system_permissions(&input.project, &doc).await?;
}
}
if let Some(policy) = template.default_mcp_tool_policy.clone() {
if let Some(store) = &self.mcp_tool_permissions {
let mut doc = store.load_mcp_tool_permissions(&input.project).await?;
doc.agents
.retain(|override_| override_.agent_id != agent.id);
doc.agents
.push(domain::AgentMcpToolPolicyOverride::new(agent.id, policy));
store
.save_mcp_tool_permissions(&input.project, &doc)
.await?;
}
}
self.events.publish(DomainEvent::LayoutChanged { self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id, project_id: input.project.id,
}); });

View File

@ -10,10 +10,15 @@ use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId}; use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
AgentContextStore, EventBus, EventStream, IdGenerator, StoreError, TemplateStore, AgentContextStore, EventBus, EventStream, IdGenerator, McpToolPermissionStore, StoreError,
SystemPermissionStore, TemplateStore,
}; };
use domain::template::{AgentTemplate, TemplateVersion}; use domain::template::{AgentTemplate, TemplateVersion};
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef}; use domain::{
AgentManifest, AgentMcpToolPolicyOverride, AgentSystemPermissionOverride, ManifestEntry,
McpToolPolicy, NetworkPolicy, Project, ProjectMcpToolPermissions, ProjectPath,
ProjectSystemPermissions, RemoteRef, SystemPermissionSet,
};
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
@ -147,6 +152,62 @@ impl SeqIds {
Self(Mutex::new(1)) Self(Mutex::new(1))
} }
} }
#[derive(Clone)]
struct FakeSystemPermissions(Arc<Mutex<ProjectSystemPermissions>>);
impl Default for FakeSystemPermissions {
fn default() -> Self {
Self(Arc::new(Mutex::new(ProjectSystemPermissions::default())))
}
}
#[async_trait]
impl SystemPermissionStore for FakeSystemPermissions {
async fn load_system_permissions(
&self,
_project: &Project,
) -> Result<ProjectSystemPermissions, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save_system_permissions(
&self,
_project: &Project,
permissions: &ProjectSystemPermissions,
) -> Result<(), StoreError> {
*self.0.lock().unwrap() = permissions.clone();
Ok(())
}
}
#[derive(Clone)]
struct FakeMcpToolPermissions(Arc<Mutex<ProjectMcpToolPermissions>>);
impl Default for FakeMcpToolPermissions {
fn default() -> Self {
Self(Arc::new(Mutex::new(ProjectMcpToolPermissions::default())))
}
}
#[async_trait]
impl McpToolPermissionStore for FakeMcpToolPermissions {
async fn load_mcp_tool_permissions(
&self,
_project: &Project,
) -> Result<ProjectMcpToolPermissions, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save_mcp_tool_permissions(
&self,
_project: &Project,
permissions: &ProjectMcpToolPermissions,
) -> Result<(), StoreError> {
*self.0.lock().unwrap() = permissions.clone();
Ok(())
}
}
impl IdGenerator for SeqIds { impl IdGenerator for SeqIds {
fn new_uuid(&self) -> Uuid { fn new_uuid(&self) -> Uuid {
let mut n = self.0.lock().unwrap(); let mut n = self.0.lock().unwrap();
@ -216,6 +277,9 @@ async fn create_template_starts_at_initial_version() {
name: "Backend".to_owned(), name: "Backend".to_owned(),
content: "# ctx".to_owned(), content: "# ctx".to_owned(),
default_profile_id: pid(7), default_profile_id: pid(7),
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
}) })
.await .await
.unwrap(); .unwrap();
@ -254,7 +318,12 @@ async fn update_template_bumps_version_and_publishes_event() {
let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone())) let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone()))
.execute(UpdateTemplateInput { .execute(UpdateTemplateInput {
template_id: tid(1), template_id: tid(1),
content: "v2".to_owned(), content: Some("v2".to_owned()),
name: None,
default_profile_id: None,
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
}) })
.await .await
.unwrap(); .unwrap();
@ -278,13 +347,74 @@ async fn update_unknown_template_is_not_found() {
) )
.execute(UpdateTemplateInput { .execute(UpdateTemplateInput {
template_id: tid(404), template_id: tid(404),
content: "x".to_owned(), content: Some("x".to_owned()),
name: None,
default_profile_id: None,
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
}) })
.await .await
.unwrap_err(); .unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
} }
#[tokio::test]
async fn update_template_updates_default_profile_for_future_agents() {
let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]);
let updated = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(SpyBus::default()))
.execute(UpdateTemplateInput {
template_id: tid(1),
content: Some("v2".to_owned()),
name: Some("Renamed".to_owned()),
default_profile_id: Some(pid(42)),
default_agent_name: Some(Some("Agent Name".to_owned())),
default_system_permissions: Some(Some(SystemPermissionSet::new(Some(
NetworkPolicy::Deny,
)))),
default_mcp_tool_policy: Some(Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
})),
})
.await
.unwrap()
.template;
assert_eq!(updated.name, "Renamed");
assert_eq!(updated.default_profile_id, pid(42));
assert_eq!(updated.default_agent_name.as_deref(), Some("Agent Name"));
assert_eq!(
updated.default_system_permissions,
Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny)))
);
assert_eq!(
updated.default_mcp_tool_policy,
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()]
})
);
let contexts = FakeContexts::new(vec![]);
let out = CreateAgentFromTemplate::new(
Arc::new(store),
Arc::new(contexts),
Arc::new(SeqIds::new()),
Arc::new(SpyBus::default()),
)
.execute(CreateAgentFromTemplateInput {
project: project(),
template_id: tid(1),
name: None,
synchronized: true,
})
.await
.unwrap();
assert_eq!(out.agent.name, "Agent Name");
assert_eq!(out.agent.profile_id, pid(42));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateAgentFromTemplate // CreateAgentFromTemplate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -327,6 +457,62 @@ async fn create_agent_from_template_links_origin_and_seeds_context() {
assert_eq!(contexts.manifest().entries.len(), 1); assert_eq!(contexts.manifest().entries.len(), 1);
} }
#[tokio::test]
async fn create_agent_from_template_projects_template_permission_overrides() {
let mut template = template(tid(1), "Backend Template", "# body", 1);
template = template
.with_metadata(
"Backend Template".to_owned(),
pid(7),
Some("Backend".to_owned()),
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
)
.unwrap();
let store = FakeTemplates::with(vec![template]);
let contexts = FakeContexts::new(vec![]);
let system_permissions = FakeSystemPermissions::default();
let mcp_tool_permissions = FakeMcpToolPermissions::default();
let out = CreateAgentFromTemplate::new(
Arc::new(store),
Arc::new(contexts),
Arc::new(SeqIds::new()),
Arc::new(SpyBus::default()),
)
.with_system_permission_store(Arc::new(system_permissions.clone()))
.with_mcp_tool_permission_store(Arc::new(mcp_tool_permissions.clone()))
.execute(CreateAgentFromTemplateInput {
project: project(),
template_id: tid(1),
name: None,
synchronized: true,
})
.await
.unwrap();
assert_eq!(out.agent.name, "Backend");
assert_eq!(out.agent.profile_id, pid(7));
assert_eq!(
system_permissions.0.lock().unwrap().agents,
vec![AgentSystemPermissionOverride::new(
out.agent.id,
SystemPermissionSet::new(Some(NetworkPolicy::Allow))
)]
);
assert_eq!(
mcp_tool_permissions.0.lock().unwrap().agents,
vec![AgentMcpToolPolicyOverride::new(
out.agent.id,
McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()]
}
)]
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DetectAgentDrift // DetectAgentDrift
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -6,6 +6,7 @@
//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain //! TypeScript side sees idiomatic camelCase. This matches the persisted-domain
//! JSON convention already used in the domain (`agents.json` etc.). //! JSON convention already used in the domain (`agents.json` etc.).
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@ -4289,6 +4290,19 @@ pub struct CreateTemplateRequestDto {
pub content: String, pub content: String,
/// Default runtime profile id for agents created from this template. /// Default runtime profile id for agents created from this template.
pub default_profile_id: String, pub default_profile_id: String,
/// Optional default agent name for agents created from this template.
#[serde(default)]
pub default_agent_name: Option<String>,
/// Optional agent-level system permission override for created agents.
#[serde(default)]
pub default_system_permissions: Option<SystemPermissionSet>,
/// Optional agent-level MCP tool policy override for created agents.
#[serde(
default,
rename = "defaultMcpToolPermissions",
alias = "defaultMcpToolPolicy"
)]
pub default_mcp_tool_policy: Option<McpToolPolicy>,
} }
impl CreateTemplateRequestDto { impl CreateTemplateRequestDto {
@ -4301,18 +4315,73 @@ impl CreateTemplateRequestDto {
name: self.name, name: self.name,
content: self.content, content: self.content,
default_profile_id: parse_profile_id(&self.default_profile_id)?, default_profile_id: parse_profile_id(&self.default_profile_id)?,
default_agent_name: self.default_agent_name,
default_system_permissions: self.default_system_permissions,
default_mcp_tool_policy: self.default_mcp_tool_policy,
}) })
} }
} }
/// Request DTO for `update_template`. /// Request DTO for `update_template`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTemplateRequestDto { pub struct UpdateTemplateRequestDto {
/// Id of the template to update. /// Id of the template to update.
pub template_id: String, pub template_id: String,
/// New Markdown content. /// New Markdown content. Missing/null leaves it unchanged.
pub content: String, pub content: Option<String>,
/// New display name. Missing leaves it unchanged.
pub name: Option<String>,
/// New default runtime profile. Missing leaves it unchanged.
pub default_profile_id: Option<String>,
/// New default agent name. Missing leaves it unchanged; null clears it.
pub default_agent_name: Option<Option<String>>,
/// New default system permissions. Missing leaves it unchanged; null clears it.
pub default_system_permissions: Option<Option<SystemPermissionSet>>,
/// New default MCP tool policy. Missing leaves it unchanged; null clears it.
pub default_mcp_tool_policy: Option<Option<McpToolPolicy>>,
}
impl<'de> Deserialize<'de> for UpdateTemplateRequestDto {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut value = Value::deserialize(deserializer)?;
let object = value
.as_object_mut()
.ok_or_else(|| serde::de::Error::custom("expected update_template object"))?;
let template_id =
take_required_string(object, "templateId").map_err(serde::de::Error::custom)?;
let content = take_optional_value::<String>(object, "content")
.map_err(serde::de::Error::custom)?
.flatten();
let name = take_optional_value::<String>(object, "name")
.map_err(serde::de::Error::custom)?
.flatten();
let default_profile_id = take_optional_value::<String>(object, "defaultProfileId")
.map_err(serde::de::Error::custom)?
.flatten();
let default_agent_name = take_optional_value::<String>(object, "defaultAgentName")
.map_err(serde::de::Error::custom)?;
let default_system_permissions =
take_optional_value::<SystemPermissionSet>(object, "defaultSystemPermissions")
.map_err(serde::de::Error::custom)?;
let default_mcp_tool_policy = take_optional_value_alias::<McpToolPolicy>(
object,
"defaultMcpToolPermissions",
"defaultMcpToolPolicy",
)
.map_err(serde::de::Error::custom)?;
Ok(Self {
template_id,
content,
name,
default_profile_id,
default_agent_name,
default_system_permissions,
default_mcp_tool_policy,
})
}
} }
impl UpdateTemplateRequestDto { impl UpdateTemplateRequestDto {
@ -4324,10 +4393,66 @@ impl UpdateTemplateRequestDto {
Ok(UpdateTemplateInput { Ok(UpdateTemplateInput {
template_id: parse_template_id(&self.template_id)?, template_id: parse_template_id(&self.template_id)?,
content: self.content, content: self.content,
name: self.name,
default_profile_id: self
.default_profile_id
.as_deref()
.map(parse_profile_id)
.transpose()?,
default_agent_name: self.default_agent_name,
default_system_permissions: self.default_system_permissions,
default_mcp_tool_policy: self.default_mcp_tool_policy,
}) })
} }
} }
fn take_required_string(
object: &mut serde_json::Map<String, Value>,
key: &str,
) -> Result<String, String> {
match object.remove(key) {
Some(Value::String(value)) => Ok(value),
Some(_) => Err(format!("{key} must be a string")),
None => Err(format!("missing {key}")),
}
}
fn take_optional_value<T>(
object: &mut serde_json::Map<String, Value>,
key: &str,
) -> Result<Option<Option<T>>, String>
where
T: DeserializeOwned,
{
match object.remove(key) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(value) => serde_json::from_value(value)
.map(Some)
.map(Some)
.map_err(|err| format!("invalid {key}: {err}")),
}
}
fn take_optional_value_alias<T>(
object: &mut serde_json::Map<String, Value>,
primary_key: &str,
legacy_key: &str,
) -> Result<Option<Option<T>>, String>
where
T: DeserializeOwned,
{
let primary = take_optional_value(object, primary_key)?;
let legacy = take_optional_value(object, legacy_key)?;
match (primary, legacy) {
(Some(value), None) | (None, Some(value)) => Ok(Some(value)),
(None, None) => Ok(None),
(Some(_), Some(_)) => Err(format!(
"{primary_key} and {legacy_key} cannot both be provided"
)),
}
}
/// Parses a template-id string (UUID) coming from the frontend. /// Parses a template-id string (UUID) coming from the frontend.
/// ///
/// # Errors /// # Errors

View File

@ -2392,12 +2392,16 @@ impl BackendCore {
)); ));
let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port))); let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port)));
let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port))); let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port)));
let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new( let create_agent_from_template = Arc::new(
Arc::clone(&template_store_port), CreateAgentFromTemplate::new(
Arc::clone(&contexts_port), Arc::clone(&template_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>, Arc::clone(&contexts_port),
Arc::clone(&events_port), Arc::clone(&ids) as Arc<dyn IdGenerator>,
)); Arc::clone(&events_port),
)
.with_system_permission_store(Arc::clone(&system_permission_store_port))
.with_mcp_tool_permission_store(Arc::clone(&mcp_tool_permission_store_port)),
);
let detect_agent_drift = Arc::new(DetectAgentDrift::new( let detect_agent_drift = Arc::new(DetectAgentDrift::new(
Arc::clone(&template_store_port), Arc::clone(&template_store_port),
Arc::clone(&contexts_port), Arc::clone(&contexts_port),

View File

@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
use crate::error::DomainError; use crate::error::DomainError;
use crate::ids::{ProfileId, TemplateId}; use crate::ids::{ProfileId, TemplateId};
use crate::markdown::MarkdownDoc; use crate::markdown::MarkdownDoc;
use crate::mcp_tool_permissions::McpToolPolicy;
use crate::system_permissions::SystemPermissionSet;
/// Monotonically increasing template version. /// Monotonically increasing template version.
/// ///
@ -55,6 +57,23 @@ pub struct AgentTemplate {
pub version: TemplateVersion, pub version: TemplateVersion,
/// Default runtime profile for agents created from this template. /// Default runtime profile for agents created from this template.
pub default_profile_id: ProfileId, pub default_profile_id: ProfileId,
/// Default agent name for agents created from this template. `None` falls
/// back to the template display name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_agent_name: Option<String>,
/// Agent-level system permission override to apply at creation time.
/// `None` preserves project inheritance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_system_permissions: Option<SystemPermissionSet>,
/// Agent-level MCP tool policy override to apply at creation time.
/// `None` preserves project inheritance.
#[serde(
default,
rename = "defaultMcpToolPermissions",
alias = "defaultMcpToolPolicy",
skip_serializing_if = "Option::is_none"
)]
pub default_mcp_tool_policy: Option<McpToolPolicy>,
} }
impl AgentTemplate { impl AgentTemplate {
@ -76,6 +95,9 @@ impl AgentTemplate {
content_md, content_md,
version: TemplateVersion::INITIAL, version: TemplateVersion::INITIAL,
default_profile_id, default_profile_id,
default_agent_name: None,
default_system_permissions: None,
default_mcp_tool_policy: None,
}) })
} }
@ -89,4 +111,32 @@ impl AgentTemplate {
..self.clone() ..self.clone()
} }
} }
/// Returns a copy with template metadata changed. The template version is
/// left unchanged because these defaults affect future agent creation only.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `name` or a provided default agent
/// name is empty.
pub fn with_metadata(
&self,
name: String,
default_profile_id: ProfileId,
default_agent_name: Option<String>,
default_system_permissions: Option<SystemPermissionSet>,
default_mcp_tool_policy: Option<McpToolPolicy>,
) -> Result<Self, DomainError> {
crate::validation::non_empty(&name, "template.name")?;
if let Some(default_agent_name) = &default_agent_name {
crate::validation::non_empty(default_agent_name, "template.defaultAgentName")?;
}
Ok(Self {
name,
default_profile_id,
default_agent_name,
default_system_permissions,
default_mcp_tool_policy,
..self.clone()
})
}
} }

View File

@ -98,7 +98,26 @@ pub fn catalogue() -> Vec<ToolDef> {
"properties": { "properties": {
"name": { "type": "string" }, "name": { "type": "string" },
"content": { "type": "string" }, "content": { "type": "string" },
"defaultProfileId": { "type": "string", "format": "uuid" } "defaultProfileId": { "type": "string", "format": "uuid" },
"defaultAgentName": { "type": ["string", "null"] },
"defaultSystemPermissions": {
"type": ["object", "null"],
"properties": {
"network": { "type": "string", "enum": ["allow", "deny", "ask"] }
},
"additionalProperties": false
},
"defaultMcpToolPermissions": {
"type": ["object", "null"],
"properties": {
"allowedTools": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["allowedTools"],
"additionalProperties": false
}
}, },
"required": ["name", "content", "defaultProfileId"], "required": ["name", "content", "defaultProfileId"],
"additionalProperties": false "additionalProperties": false
@ -106,14 +125,35 @@ pub fn catalogue() -> Vec<ToolDef> {
}, },
ToolDef { ToolDef {
name: "idea_template_update", name: "idea_template_update",
description: "Update a global IdeA agent template's Markdown content.", description: "Update a global IdeA agent template.",
input_schema: json!({ input_schema: json!({
"type": "object", "type": "object",
"properties": { "properties": {
"templateId": template_id.clone(), "templateId": template_id.clone(),
"content": { "type": "string" } "content": { "type": ["string", "null"] },
"name": { "type": "string" },
"defaultProfileId": { "type": "string", "format": "uuid" },
"defaultAgentName": { "type": ["string", "null"] },
"defaultSystemPermissions": {
"type": ["object", "null"],
"properties": {
"network": { "type": "string", "enum": ["allow", "deny", "ask"] }
},
"additionalProperties": false
},
"defaultMcpToolPermissions": {
"type": ["object", "null"],
"properties": {
"allowedTools": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["allowedTools"],
"additionalProperties": false
}
}, },
"required": ["templateId", "content"], "required": ["templateId"],
"additionalProperties": false "additionalProperties": false
}), }),
}, },

View File

@ -28,6 +28,7 @@ use domain::ids::{ProfileId, TemplateId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore}; use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
use domain::template::{AgentTemplate, TemplateVersion}; use domain::template::{AgentTemplate, TemplateVersion};
use domain::{McpToolPolicy, SystemPermissionSet};
/// Directory (under app-data) holding the templates store. /// Directory (under app-data) holding the templates store.
const TEMPLATES_DIR: &str = "templates"; const TEMPLATES_DIR: &str = "templates";
@ -47,6 +48,17 @@ struct IndexEntry {
version: TemplateVersion, version: TemplateVersion,
content_hash: String, content_hash: String,
default_profile_id: ProfileId, default_profile_id: ProfileId,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_agent_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_system_permissions: Option<SystemPermissionSet>,
#[serde(
default,
rename = "defaultMcpToolPermissions",
alias = "defaultMcpToolPolicy",
skip_serializing_if = "Option::is_none"
)]
default_mcp_tool_policy: Option<McpToolPolicy>,
} }
/// On-disk shape of `templates/index.json`. /// On-disk shape of `templates/index.json`.
@ -155,6 +167,9 @@ impl FsTemplateStore {
content_md: MarkdownDoc::new(content), content_md: MarkdownDoc::new(content),
version: entry.version, version: entry.version,
default_profile_id: entry.default_profile_id, default_profile_id: entry.default_profile_id,
default_agent_name: entry.default_agent_name.clone(),
default_system_permissions: entry.default_system_permissions.clone(),
default_mcp_tool_policy: entry.default_mcp_tool_policy.clone(),
}) })
} }
} }
@ -202,6 +217,9 @@ impl TemplateStore for FsTemplateStore {
version: template.version, version: template.version,
content_hash: content_hash(&template.content_md), content_hash: content_hash(&template.content_md),
default_profile_id: template.default_profile_id, default_profile_id: template.default_profile_id,
default_agent_name: template.default_agent_name.clone(),
default_system_permissions: template.default_system_permissions.clone(),
default_mcp_tool_policy: template.default_mcp_tool_policy.clone(),
}; };
if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) { if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) {
*slot = row; *slot = row;

View File

@ -9,6 +9,7 @@ use domain::ids::{ProfileId, TemplateId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore}; use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
use domain::template::AgentTemplate; use domain::template::AgentTemplate;
use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet};
use infrastructure::{FsTemplateStore, LocalFileSystem}; use infrastructure::{FsTemplateStore, LocalFileSystem};
use uuid::Uuid; use uuid::Uuid;
@ -60,6 +61,17 @@ async fn save_then_get_and_list_roundtrip() {
let store = store(&tmp); let store = store(&tmp);
let t = template(tid(1), "Backend", "# Backend template"); let t = template(tid(1), "Backend", "# Backend template");
let t = t
.with_metadata(
"Backend".to_owned(),
pid(2),
Some("Backend Agent".to_owned()),
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
)
.unwrap();
store.save(&t).await.unwrap(); store.save(&t).await.unwrap();
assert_eq!(store.get(tid(1)).await.unwrap(), t); assert_eq!(store.get(tid(1)).await.unwrap(), t);
@ -116,10 +128,18 @@ async fn delete_removes_from_index() {
async fn index_is_camelcase_with_content_hash() { async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new(); let tmp = TempDir::new();
let store = store(&tmp); let store = store(&tmp);
store let template = template(tid(1), "Backend", "hello")
.save(&template(tid(1), "Backend", "hello")) .with_metadata(
.await "Backend".to_owned(),
pid(2),
Some("Backend Agent".to_owned()),
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Some(McpToolPolicy {
allowed_tools: vec!["idea_template_read".to_owned()],
}),
)
.unwrap(); .unwrap();
store.save(&template).await.unwrap();
let fs = LocalFileSystem::new(); let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap(); let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap();
@ -131,6 +151,26 @@ async fn index_is_camelcase_with_content_hash() {
"camelCase contentHash present" "camelCase contentHash present"
); );
assert!(entry.get("defaultProfileId").is_some()); assert!(entry.get("defaultProfileId").is_some());
assert_eq!(
entry.get("defaultAgentName").and_then(|v| v.as_str()),
Some("Backend Agent")
);
assert_eq!(
entry
.get("defaultSystemPermissions")
.and_then(|v| v.get("network"))
.and_then(|v| v.as_str()),
Some("allow")
);
assert_eq!(
entry
.get("defaultMcpToolPermissions")
.and_then(|v| v.get("allowedTools"))
.and_then(|v| v.as_array())
.and_then(|values| values.first())
.and_then(|v| v.as_str()),
Some("idea_template_read")
);
assert!(entry.get("content_hash").is_none(), "no snake_case leak"); assert!(entry.get("content_hash").is_none(), "no snake_case leak");
} }

View File

@ -60,6 +60,7 @@ import type {
CreateMemoryInput, CreateMemoryInput,
CreateSkillInput, CreateSkillInput,
CreateTemplateInput, CreateTemplateInput,
UpdateTemplateInput,
EmbedderGateway, EmbedderGateway,
GitGateway, GitGateway,
InputGateway, InputGateway,
@ -286,11 +287,28 @@ export class HttpTemplateGateway implements TemplateGateway {
} }
createTemplate(input: CreateTemplateInput): Promise<Template> { createTemplate(input: CreateTemplateInput): Promise<Template> {
return this.http.invoke<Template>("create_template", { return this.http.invoke<Template>("create_template", {
request: { name: input.name, content: input.content, defaultProfileId: input.defaultProfileId }, request: {
name: input.name,
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
systemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
}); });
} }
updateTemplate(templateId: string, content: string): Promise<Template> { updateTemplate(templateId: string, input: UpdateTemplateInput): Promise<Template> {
return this.http.invoke<Template>("update_template", { request: { templateId, content } }); return this.http.invoke<Template>("update_template", {
request: {
templateId,
name: input.name,
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
systemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
});
} }
async deleteTemplate(templateId: string): Promise<void> { async deleteTemplate(templateId: string): Promise<void> {
await this.http.invoke("delete_template", { templateId }); await this.http.invoke("delete_template", { templateId });

View File

@ -137,9 +137,9 @@ describe("WsLiveClient structured chat routing", () => {
expect(client.getConnectionState()).toBe("reconnecting"); expect(client.getConnectionState()).toBe("reconnecting");
expect(client.needsReconnect()).toBe(true); expect(client.needsReconnect()).toBe(true);
const fire = timerFn; const fire = timerFn as unknown as () => void;
timerFn = null; timerFn = null;
fire?.(); fire();
await vi.waitFor(() => expect(sockets.length).toBe(2)); await vi.waitFor(() => expect(sockets.length).toBe(2));
await vi.waitFor(() => expect(sockets[1].sent.length).toBeGreaterThan(0)); await vi.waitFor(() => expect(sockets[1].sent.length).toBeGreaterThan(0));

View File

@ -118,6 +118,7 @@ import type {
DeviceGateway, DeviceGateway,
EmbedderGateway, EmbedderGateway,
CreateTemplateInput, CreateTemplateInput,
UpdateTemplateInput,
Gateways, Gateways,
InputGateway, InputGateway,
LiveAgent, LiveAgent,
@ -2230,12 +2231,18 @@ export class MockTemplateGateway implements TemplateGateway {
contentMd: input.content, contentMd: input.content,
version: 1, version: 1,
defaultProfileId: input.defaultProfileId, defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
systemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
}; };
this.templates.push(template); this.templates.push(template);
return structuredClone(template); return structuredClone(template);
} }
async updateTemplate(templateId: string, content: string): Promise<Template> { async updateTemplate(
templateId: string,
input: UpdateTemplateInput | string,
): Promise<Template> {
const idx = this.templates.findIndex((t) => t.id === templateId); const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) { if (idx === -1) {
const err: GatewayError = { const err: GatewayError = {
@ -2244,10 +2251,27 @@ export class MockTemplateGateway implements TemplateGateway {
}; };
throw err; throw err;
} }
const current = this.templates[idx];
const update =
typeof input === "string"
? {
name: current.name,
content: input,
defaultProfileId: current.defaultProfileId,
defaultAgentName: current.defaultAgentName ?? null,
systemPermissions: current.systemPermissions ?? null,
mcpToolPermissions: current.mcpToolPermissions ?? null,
}
: input;
this.templates[idx] = { this.templates[idx] = {
...this.templates[idx], ...current,
contentMd: content, name: update.name,
version: this.templates[idx].version + 1, contentMd: update.content,
defaultProfileId: update.defaultProfileId,
defaultAgentName: update.defaultAgentName ?? null,
systemPermissions: update.systemPermissions ?? null,
mcpToolPermissions: update.mcpToolPermissions ?? null,
version: current.version + 1,
}; };
return structuredClone(this.templates[idx]); return structuredClone(this.templates[idx]);
} }
@ -2279,7 +2303,7 @@ export class MockTemplateGateway implements TemplateGateway {
} }
const synchronized = opts?.synchronized ?? true; const synchronized = opts?.synchronized ?? true;
const name = opts?.name ?? template.name; const name = opts?.name ?? template.defaultAgentName ?? template.name;
const slug = slugify(name) || "agent"; const slug = slugify(name) || "agent";
// Derive unique contextPath (same logic as MockAgentGateway) // Derive unique contextPath (same logic as MockAgentGateway)

View File

@ -13,7 +13,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Agent, AgentDrift, Template } from "@/domain"; import type { Agent, AgentDrift, Template } from "@/domain";
import type { CreateTemplateInput, TemplateGateway } from "@/ports"; import type { CreateTemplateInput, TemplateGateway, UpdateTemplateInput } from "@/ports";
export class TauriTemplateGateway implements TemplateGateway { export class TauriTemplateGateway implements TemplateGateway {
listTemplates(): Promise<Template[]> { listTemplates(): Promise<Template[]> {
@ -26,13 +26,24 @@ export class TauriTemplateGateway implements TemplateGateway {
name: input.name, name: input.name,
content: input.content, content: input.content,
defaultProfileId: input.defaultProfileId, defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
systemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
}, },
}); });
} }
updateTemplate(templateId: string, content: string): Promise<Template> { updateTemplate(templateId: string, input: UpdateTemplateInput): Promise<Template> {
return invoke<Template>("update_template", { return invoke<Template>("update_template", {
request: { templateId, content }, request: {
templateId,
name: input.name,
content: input.content,
defaultProfileId: input.defaultProfileId,
defaultAgentName: input.defaultAgentName ?? null,
systemPermissions: input.systemPermissions ?? null,
mcpToolPermissions: input.mcpToolPermissions ?? null,
},
}); });
} }

View File

@ -1500,6 +1500,9 @@ export interface Template {
contentMd: string; contentMd: string;
version: number; version: number;
defaultProfileId: string; defaultProfileId: string;
defaultAgentName?: string | null;
systemPermissions?: SystemPermissionSet | null;
mcpToolPermissions?: McpToolPolicy | null;
} }
/** /**

View File

@ -20,6 +20,7 @@ import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
import { TerminalView } from "@/features/terminals/TerminalView"; import { TerminalView } from "@/features/terminals/TerminalView";
import { AnnouncementsPreview } from "@/features/announcements"; import { AnnouncementsPreview } from "@/features/announcements";
import { useDrift } from "@/features/templates/useDrift"; import { useDrift } from "@/features/templates/useDrift";
import { templateProfileLabel } from "@/features/templates/templatePresentation";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { useAgents } from "./useAgents"; import { useAgents } from "./useAgents";
import { correlateModelServerStatus } from "./modelServerLaunch"; import { correlateModelServerStatus } from "./modelServerLaunch";
@ -203,7 +204,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
} }
} }
const canCreate = newName.trim().length > 0 && !vm.busy; const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null;
const selectedTemplateMissingProfile =
selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0;
const canCreate =
newName.trim().length > 0 && !vm.busy && !selectedTemplateMissingProfile;
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@ -225,6 +230,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
setNewTemplateId(""); setNewTemplateId("");
} }
function handleTemplateChange(templateId: string) {
setNewTemplateId(templateId);
const next = templates.find((template) => template.id === templateId);
if (next?.defaultAgentName?.trim()) {
setNewName(next.defaultAgentName.trim());
}
}
function handleLaunch(agentId: string) { function handleLaunch(agentId: string) {
setPanelNodeIds((prev) => ({ setPanelNodeIds((prev) => ({
...prev, ...prev,
@ -238,10 +251,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
setActiveAgentId(null); setActiveAgentId(null);
} }
const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null;
// Determine if a template is chosen → profile selector is hidden (template imposes it). // Determine if a template is chosen → profile selector is hidden (template imposes it).
const hasTemplate = newTemplateId !== ""; const hasTemplate = newTemplateId !== "";
const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null;
const profileLabel = (profile: AgentProfile): string => { const profileLabel = (profile: AgentProfile): string => {
const model = const model =
@ -297,7 +309,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
id="agent-template-select" id="agent-template-select"
aria-label="agent template" aria-label="agent template"
value={newTemplateId} value={newTemplateId}
onChange={setNewTemplateId} onChange={handleTemplateChange}
className="w-full" className="w-full"
disabled={vm.busy} disabled={vm.busy}
size="md" size="md"
@ -306,6 +318,28 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
...templates.map((t) => ({ value: t.id, label: t.name })), ...templates.map((t) => ({ value: t.id, label: t.name })),
]} ]}
/> />
{selectedTemplate && (
<div className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted">
<div>
Nom initial:{" "}
<span className="text-content">
{selectedTemplate.defaultAgentName || selectedTemplate.name}
</span>
</div>
<div>
Modèle appliqué:{" "}
<span className="text-content">
{templateProfileLabel(selectedTemplate, vm.profiles)}
</span>
</div>
{selectedTemplateMissingProfile && (
<p role="alert" className="mt-1 text-danger">
Ce template n'a pas de modèle AI par défaut. Choisis un autre
template ou édite-le avant de créer l'agent.
</p>
)}
</div>
)}
</div> </div>
{/* Profile selector — hidden when a template is chosen */} {/* Profile selector — hidden when a template is chosen */}

View File

@ -391,6 +391,38 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await screen.findByText("From Template"); await screen.findByText("From Template");
}); });
it("selecting a template pre-fills its default agent name and applies its default profile", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
const t = await tmpl.createTemplate({
name: "Main template",
content: "# ctx",
defaultProfileId: "profile-main",
defaultAgentName: "Main",
});
const createFromTemplateSpy = vi.spyOn(tmpl, "createAgentFromTemplate");
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
await waitForIdle();
chooseDropdownOption("agent template", "Main template");
expect((screen.getByLabelText("agent name") as HTMLInputElement).value).toBe("Main");
expect(screen.getByText(/Modèle appliqué:/)).toBeTruthy();
expect(screen.getByText("Profil profile-main")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "create agent" }));
await waitFor(() => {
expect(createFromTemplateSpy).toHaveBeenCalledWith(PROJECT_ID, t.id, {
name: "Main",
synchronized: true,
});
});
const agents = await agent.listAgents(PROJECT_ID);
expect(agents[0].name).toBe("Main");
expect(agents[0].profileId).toBe("profile-main");
});
it("shows profiles in the dropdown when profiles are configured", async () => { it("shows profiles in the dropdown when profiles are configured", async () => {
const profile = new MockProfileGateway(); const profile = new MockProfileGateway();
await profile.configureProfiles([ await profile.configureProfiles([

View File

@ -1,41 +1,48 @@
/** import { useMemo, useState } from "react";
* `TemplateEditor` — a fullscreen overlay for creating or editing agent
* templates (L7). Rendered on top of the rest of the UI (`fixed inset-0`).
*
* Provides:
* - Name field and default-profile selector
* - Two tabs: "Edit" (textarea) and "Preview" (react-markdown)
* - Save (create/update) and Cancel/Close buttons
*
* Pure presentation: all mutations are delegated to the callbacks supplied by
* the parent (`TemplatesPanel`). Styled with `@/shared`; no inline styles.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { AgentProfile, Template } from "@/domain"; import type {
AgentProfile,
McpToolPolicy,
NetworkPolicy,
SystemPermissionSet,
Template,
} from "@/domain";
import { MCP_TOOL_GROUPS, mcpToolLabel } from "@/features/permissions/mcpToolGroups";
import { Button, Input, SmallDropdown, cn } from "@/shared"; import { Button, Input, SmallDropdown, cn } from "@/shared";
import { profileLabel } from "./templatePresentation";
export interface TemplateEditorSaveInput {
name: string;
content: string;
defaultProfileId: string;
defaultAgentName: string | null;
systemPermissions: SystemPermissionSet | null;
mcpToolPermissions: McpToolPolicy | null;
}
export interface TemplateEditorProps { export interface TemplateEditorProps {
/**
* When set, the editor is in edit-mode for the given template; otherwise it
* is in create-mode (all fields start empty).
*/
template?: Template | null; template?: Template | null;
/** Available profiles for the default-profile selector. */
profiles: AgentProfile[]; profiles: AgentProfile[];
/** Called when the user submits the form. */ onSave: (input: TemplateEditorSaveInput) => Promise<void>;
onSave: (name: string, content: string, defaultProfileId: string) => Promise<void>;
/** Called when the user cancels / closes the overlay. */
onClose: () => void; onClose: () => void;
/** Whether a save operation is in flight. */
busy?: boolean; busy?: boolean;
} }
type EditorTab = "edit" | "preview"; type EditorTab = "edit" | "preview";
const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "", label: "Aucune permission système" },
{ value: "ask", label: "Réseau sur demande" },
{ value: "allow", label: "Réseau autorisé" },
{ value: "deny", label: "Réseau refusé" },
];
const ALL_MCP_TOOLS = Array.from(
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
);
export function TemplateEditor({ export function TemplateEditor({
template, template,
profiles, profiles,
@ -45,17 +52,43 @@ export function TemplateEditor({
}: TemplateEditorProps) { }: TemplateEditorProps) {
const [name, setName] = useState(template?.name ?? ""); const [name, setName] = useState(template?.name ?? "");
const [content, setContent] = useState(template?.contentMd ?? ""); const [content, setContent] = useState(template?.contentMd ?? "");
const [defaultAgentName, setDefaultAgentName] = useState(
template?.defaultAgentName ?? "",
);
const [defaultProfileId, setDefaultProfileId] = useState( const [defaultProfileId, setDefaultProfileId] = useState(
template?.defaultProfileId ?? "", template?.defaultProfileId ?? "",
); );
const [network, setNetwork] = useState<"" | NetworkPolicy>(
template?.systemPermissions?.network ?? "",
);
const [allowedTools, setAllowedTools] = useState<string[]>(
template?.mcpToolPermissions?.allowedTools ?? [],
);
const [tab, setTab] = useState<EditorTab>("edit"); const [tab, setTab] = useState<EditorTab>("edit");
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
const canSave = name.trim().length > 0 && !busy; const canSave = name.trim().length > 0 && !busy;
function toggleTool(tool: string) {
setAllowedTools((prev) =>
prev.includes(tool)
? prev.filter((candidate) => candidate !== tool)
: [...prev, tool],
);
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!canSave) return; if (!canSave) return;
await onSave(name.trim(), content, defaultProfileId); await onSave({
name: name.trim(),
content,
defaultProfileId,
defaultAgentName: defaultAgentName.trim() || null,
systemPermissions: network ? { network } : null,
mcpToolPermissions:
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
});
} }
return ( return (
@ -65,7 +98,6 @@ export function TemplateEditor({
aria-modal="true" aria-modal="true"
aria-label="template editor" aria-label="template editor"
> >
{/* ── Header bar ── */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3"> <div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
<h2 className="text-sm font-semibold text-content"> <h2 className="text-sm font-semibold text-content">
{template ? `Edit template — ${template.name}` : "New template"} {template ? `Edit template — ${template.name}` : "New template"}
@ -77,141 +109,201 @@ export function TemplateEditor({
onClick={onClose} onClick={onClose}
disabled={busy} disabled={busy}
> >
✕ x
</Button> </Button>
</div> </div>
{/* ── Form body ── */}
<form <form
onSubmit={(e) => void handleSubmit(e)} onSubmit={(e) => void handleSubmit(e)}
className="flex flex-1 flex-col gap-0 overflow-hidden" className="flex min-h-0 flex-1 flex-col overflow-hidden"
> >
{/* ── Meta fields ── */} <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto px-4 py-3">
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3"> <div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-1"> <label htmlFor="te-name" className="text-xs font-medium text-muted">
<label Nom du template
htmlFor="te-name"
className="text-xs font-medium text-muted"
>
Name
</label> </label>
<Input <Input
id="te-name" id="te-name"
aria-label="template name" aria-label="template name"
placeholder="My template" placeholder="Template revue"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
disabled={busy} disabled={busy}
className="min-w-48"
/> />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-1"> <section className="flex min-h-64 flex-col overflow-hidden rounded-md border border-border bg-raised">
<label <div className="flex shrink-0 items-center justify-between border-b border-border px-3">
htmlFor="te-profile" <label
className="text-xs font-medium text-muted" htmlFor="te-content"
> className="py-2 text-xs font-medium text-muted"
Default profile >
</label> Description
{profiles.length > 0 ? ( </label>
<div role="tablist" aria-label="template content mode" className="flex">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
"px-3 py-2 text-xs font-medium transition-colors",
tab === t
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
</button>
))}
</div>
</div>
{tab === "edit" ? (
<textarea
id="te-content"
aria-label="template content"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
className={cn(
"min-h-60 flex-1 resize-none bg-raised px-3 py-2 font-mono text-sm text-content",
"outline-none placeholder:text-faint disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Instructions de l'agent"
/>
) : (
<div
className={cn(
"min-h-60 flex-1 overflow-auto px-6 py-4",
"prose prose-sm prose-invert max-w-none",
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
"[&_p]:text-content/90 [&_li]:text-content/90",
"[&_code]:rounded [&_code]:bg-canvas [&_code]:px-1 [&_code]:text-primary",
)}
>
{content.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
) : (
<p className="text-sm italic text-muted">Nothing to preview yet.</p>
)}
</div>
)}
</section>
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor="te-default-agent"
className="text-xs font-medium text-muted"
>
Nom par défaut de l'agent
</label>
<Input
id="te-default-agent"
aria-label="template default agent name"
placeholder="Main"
value={defaultAgentName}
onChange={(e) => setDefaultAgentName(e.target.value)}
disabled={busy}
/>
</div>
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-profile" className="text-xs font-medium text-muted">
Modèle AI par défaut
</label>
{profiles.length > 0 ? (
<SmallDropdown
id="te-profile"
aria-label="template default profile"
value={defaultProfileId}
onChange={setDefaultProfileId}
disabled={busy}
className="w-full"
size="md"
options={[
{ value: "", label: "Aucun modèle par défaut" },
...profiles.map((p) => ({ value: p.id, label: profileLabel(p) })),
]}
/>
) : (
<Input
id="te-profile"
aria-label="template default profile"
placeholder="profile-id"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
disabled={busy}
/>
)}
</div>
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-system-network" className="text-xs font-medium text-muted">
Permissions système
</label>
<SmallDropdown <SmallDropdown
id="te-profile" id="te-system-network"
aria-label="template default profile" aria-label="template system network permission"
value={defaultProfileId} value={network}
onChange={setDefaultProfileId} onChange={(value) => setNetwork(value as "" | NetworkPolicy)}
disabled={busy} disabled={busy}
className="w-full" className="w-full"
size="md" size="md"
options={[ options={NETWORK_OPTIONS}
{ value: "", label: "— none —" },
...profiles.map((p) => ({ value: p.id, label: p.name })),
]}
/> />
) : (
<Input
id="te-profile"
aria-label="template default profile"
placeholder="profile-id (optional)"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
disabled={busy}
className="min-w-48"
/>
)}
</div>
</div>
{/* ── Tab strip ── */}
<div className="flex shrink-0 items-center gap-0 border-b border-border px-4">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-sm font-medium capitalize transition-colors",
tab === t
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
</button>
))}
</div>
{/* ── Edit / Preview pane ── */}
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
{tab === "edit" ? (
<textarea
aria-label="template content"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
className={cn(
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
"border border-border outline-none transition-colors resize-none",
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Agent instructions&#10;&#10;..."
/>
) : (
<div
className={cn(
"flex-1 overflow-auto rounded-md border border-border bg-raised px-6 py-4",
"prose prose-sm prose-invert max-w-none",
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
"[&_p]:text-content/90 [&_li]:text-content/90",
"[&_code]:bg-canvas [&_code]:text-primary [&_code]:rounded [&_code]:px-1",
"[&_pre]:bg-canvas [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
"[&_a]:text-primary [&_a:hover]:underline",
"[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:text-muted",
"[&_hr]:border-border",
)}
>
{content.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
) : (
<p className="text-sm text-muted italic">Nothing to preview yet.</p>
)}
</div> </div>
)} </div>
<section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-xs font-medium text-muted">
Permissions Tools MCP IdeA
</h3>
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
</div>
{ALL_MCP_TOOLS.length === 0 ? (
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
) : (
<>
{allowedTools.length === 0 && (
<p className="text-sm text-muted">
Aucun tool MCP IdeA autorisé.
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{MCP_TOOL_GROUPS.map((group) => (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
{group.tools.map((tool) => (
<label
key={tool}
className="flex min-w-0 items-center gap-2 text-sm text-content/90"
>
<input
type="checkbox"
aria-label={`template MCP tool ${tool}`}
checked={allowed.has(tool)}
onChange={() => toggleTool(tool)}
disabled={busy}
className="h-4 w-4 shrink-0 accent-primary"
/>
<span className="truncate">{mcpToolLabel(tool)}</span>
</label>
))}
</div>
))}
</div>
</>
)}
</section>
</div> </div>
{/* ── Footer ── */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3"> <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button <Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
type="button"
variant="ghost"
onClick={onClose}
disabled={busy}
>
Cancel Cancel
</Button> </Button>
<Button <Button

View File

@ -15,12 +15,17 @@
* `TemplateEditor`; `create template` aria-label is on the submit button. * `TemplateEditor`; `create template` aria-label is on the submit button.
*/ */
import { useState } from "react"; import { useEffect, useState } from "react";
import type { Template } from "@/domain"; import type { AgentProfile, Template } from "@/domain";
import { Button, Panel, Spinner } from "@/shared"; import { Button, Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { TemplateEditor } from "./TemplateEditor"; import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor";
import {
mcpPermissionsSummary,
systemPermissionsSummary,
templateProfileLabel,
} from "./templatePresentation";
import { useTemplates } from "./useTemplates"; import { useTemplates } from "./useTemplates";
export interface TemplatesPanelProps { export interface TemplatesPanelProps {
@ -38,12 +43,32 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
const { profile } = useGateways(); const { profile } = useGateways();
// Profiles list for the editor's default-profile selector // Profiles list for the editor's default-profile selector
const [profiles, setProfiles] = useState<import("@/domain").AgentProfile[]>([]); const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [profilesLoaded, setProfilesLoaded] = useState(false); const [profilesLoaded, setProfilesLoaded] = useState(false);
const [editorState, setEditorState] = useState<EditorState | null>(null); const [editorState, setEditorState] = useState<EditorState | null>(null);
const [editorBusy, setEditorBusy] = useState(false); const [editorBusy, setEditorBusy] = useState(false);
useEffect(() => {
if (profilesLoaded) return;
let cancelled = false;
void profile
.listProfiles()
.then((list) => {
if (!cancelled) setProfiles(list);
})
.catch(() => {
// Profiles are optional here; ids are still displayed when labels cannot
// be resolved locally.
})
.finally(() => {
if (!cancelled) setProfilesLoaded(true);
});
return () => {
cancelled = true;
};
}, [profile, profilesLoaded]);
/** Lazily load profiles when the editor is first opened. */ /** Lazily load profiles when the editor is first opened. */
async function openEditor(state: EditorState) { async function openEditor(state: EditorState) {
if (!profilesLoaded) { if (!profilesLoaded) {
@ -58,20 +83,14 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
setEditorState(state); setEditorState(state);
} }
async function handleSave( async function handleSave(input: TemplateEditorSaveInput) {
name: string,
content: string,
defaultProfileId: string,
) {
if (!editorState) return; if (!editorState) return;
setEditorBusy(true); setEditorBusy(true);
try { try {
if (editorState.mode === "create") { if (editorState.mode === "create") {
await vm.createTemplate({ name, content, defaultProfileId }); await vm.createTemplate(input);
} else { } else {
// Update name isn't supported by the port (only content); if name await vm.updateTemplate(editorState.template.id, input);
// changed we skip it gracefully — the current port only updates content.
await vm.updateTemplate(editorState.template.id, content);
} }
setEditorState(null); setEditorState(null);
} finally { } finally {
@ -133,6 +152,16 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium text-content">{t.name}</span> <span className="font-medium text-content">{t.name}</span>
<span className="text-xs text-muted">v{t.version}</span> <span className="text-xs text-muted">v{t.version}</span>
<span className="text-xs text-muted">
agent initial: {t.defaultAgentName || t.name}
</span>
<span className="text-xs text-muted">
modèle: {templateProfileLabel(t, profiles)}
</span>
<span className="text-xs text-faint">
{systemPermissionsSummary(t.systemPermissions)} ·{" "}
{mcpPermissionsSummary(t.mcpToolPermissions)}
</span>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
@ -151,7 +180,10 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
aria-label={`create agent from ${t.name}`} aria-label={`create agent from ${t.name}`}
disabled={vm.busy} disabled={vm.busy}
onClick={() => onClick={() =>
void vm.createAgentFromTemplate(projectId, t.id) void vm.createAgentFromTemplate(projectId, t.id, {
name: t.defaultAgentName?.trim() || undefined,
synchronized: true,
})
} }
> >
Create agent Create agent

View File

@ -0,0 +1,53 @@
import type {
AgentProfile,
McpToolPolicy,
SystemPermissionSet,
Template,
} from "@/domain";
import { mcpToolLabel } from "@/features/permissions/mcpToolGroups";
export function profileModel(profile: AgentProfile): string | null {
return (
profile.model ??
profile.opencode?.model ??
profile.opencodeProvider?.model ??
profile.chatHttp?.model ??
null
);
}
export function profileLabel(profile: AgentProfile): string {
const model = profileModel(profile);
return model ? `${profile.name} · ${model}` : profile.name;
}
export function templateProfileLabel(
template: Template,
profiles: AgentProfile[],
): string {
const profile = profiles.find((candidate) => candidate.id === template.defaultProfileId);
if (profile) return profileLabel(profile);
return template.defaultProfileId
? `Profil ${template.defaultProfileId}`
: "Aucun modèle par défaut";
}
export function systemPermissionsSummary(
permissions?: SystemPermissionSet | null,
): string {
const network = permissions?.network;
if (!network) return "Système: aucune permission";
const labels: Record<NonNullable<SystemPermissionSet["network"]>, string> = {
allow: "réseau autorisé",
ask: "réseau sur demande",
deny: "réseau refusé",
};
return `Système: ${labels[network]}`;
}
export function mcpPermissionsSummary(policy?: McpToolPolicy | null): string {
const tools = policy?.allowedTools ?? [];
if (tools.length === 0) return "MCP: aucun tool autorisé";
if (tools.length <= 2) return `MCP: ${tools.map(mcpToolLabel).join(", ")}`;
return `MCP: ${tools.length} tools autorisés`;
}

View File

@ -152,6 +152,45 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
expect(screen.getByText("v1")).toBeTruthy(); expect(screen.getByText("v1")).toBeTruthy();
}); });
it("creates a template with default agent name, model and permissions summaries", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
renderTemplatesPanel({ agent, template: tmpl });
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
fireEvent.change(screen.getByLabelText("template name"), {
target: { value: "Main template" },
});
fireEvent.change(screen.getByLabelText("template content"), {
target: { value: "# Main instructions" },
});
fireEvent.change(screen.getByLabelText("template default agent name"), {
target: { value: "Main" },
});
fireEvent.change(screen.getByLabelText("template default profile"), {
target: { value: "profile-main" },
});
chooseDropdownOption("template system network permission", "Réseau autorisé");
fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read"));
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
await screen.findByText("Main template");
expect(screen.getByText("agent initial: Main")).toBeTruthy();
expect(screen.getByText("modèle: Profil profile-main")).toBeTruthy();
expect(screen.getByText(/Système: réseau autorisé/)).toBeTruthy();
expect(screen.getByText(/MCP: Lire un ticket/)).toBeTruthy();
const templates = await tmpl.listTemplates();
expect(templates[0]).toMatchObject({
defaultAgentName: "Main",
defaultProfileId: "profile-main",
systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
});
it("the Create button is disabled when the name is empty", async () => { it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel(); renderTemplatesPanel();
await waitForTemplatesIdle(); await waitForTemplatesIdle();
@ -234,6 +273,32 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
}); });
}); });
it("creating an agent from a template applies the displayed default name and profile", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
await tmpl.createTemplate({
name: "Worker template",
content: "## ctx",
defaultProfileId: "profile-worker",
defaultAgentName: "Worker",
});
renderTemplatesPanel({ agent, template: tmpl });
await screen.findByText("Worker template");
expect(screen.getByText("agent initial: Worker")).toBeTruthy();
expect(screen.getByText("modèle: Profil profile-worker")).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "create agent from Worker template" }),
);
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
expect(agents).toHaveLength(1);
expect(agents[0].name).toBe("Worker");
expect(agents[0].profileId).toBe("profile-worker");
});
});
it("TemplateEditor: can switch Edit/Preview tabs and Save persists the template", async () => { it("TemplateEditor: can switch Edit/Preview tabs and Save persists the template", async () => {
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent); const tmpl = new MockTemplateGateway(agent);

View File

@ -9,7 +9,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { GatewayError, Template } from "@/domain"; import type { GatewayError, Template } from "@/domain";
import type { CreateTemplateInput } from "@/ports"; import type { CreateTemplateInput, UpdateTemplateInput } from "@/ports";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
/** What the templates UI needs from this hook. */ /** What the templates UI needs from this hook. */
@ -24,8 +24,8 @@ export interface TemplatesViewModel {
refresh: () => Promise<void>; refresh: () => Promise<void>;
/** Creates a new template and refreshes the list. */ /** Creates a new template and refreshes the list. */
createTemplate: (input: CreateTemplateInput) => Promise<void>; createTemplate: (input: CreateTemplateInput) => Promise<void>;
/** Updates the content of an existing template. */ /** Updates an existing template. */
updateTemplate: (templateId: string, content: string) => Promise<void>; updateTemplate: (templateId: string, input: UpdateTemplateInput) => Promise<void>;
/** Deletes a template by id. */ /** Deletes a template by id. */
deleteTemplate: (templateId: string) => Promise<void>; deleteTemplate: (templateId: string) => Promise<void>;
/** Creates an agent from a template in the given project. */ /** Creates an agent from a template in the given project. */
@ -84,11 +84,11 @@ export function useTemplates(): TemplatesViewModel {
); );
const updateTemplate = useCallback( const updateTemplate = useCallback(
async (templateId: string, content: string) => { async (templateId: string, input: UpdateTemplateInput) => {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const updated = await template.updateTemplate(templateId, content); const updated = await template.updateTemplate(templateId, input);
setTemplates((prev) => setTemplates((prev) =>
prev.map((t) => (t.id === templateId ? updated : t)), prev.map((t) => (t.id === templateId ? updated : t)),
); );

View File

@ -629,6 +629,19 @@ export interface CreateTemplateInput {
name: string; name: string;
content: string; content: string;
defaultProfileId: string; defaultProfileId: string;
defaultAgentName?: string | null;
systemPermissions?: SystemPermissionSet | null;
mcpToolPermissions?: McpToolPolicy | null;
}
/** Input for {@link TemplateGateway.updateTemplate}. */
export interface UpdateTemplateInput {
name: string;
content: string;
defaultProfileId: string;
defaultAgentName?: string | null;
systemPermissions?: SystemPermissionSet | null;
mcpToolPermissions?: McpToolPolicy | null;
} }
/** /**
@ -640,8 +653,8 @@ export interface TemplateGateway {
listTemplates(): Promise<Template[]>; listTemplates(): Promise<Template[]>;
/** Creates a new template; returns the created template. */ /** Creates a new template; returns the created template. */
createTemplate(input: CreateTemplateInput): Promise<Template>; createTemplate(input: CreateTemplateInput): Promise<Template>;
/** Updates a template's content; increments its version; returns the updated template. */ /** Updates a template; increments its version; returns the updated template. */
updateTemplate(templateId: string, content: string): Promise<Template>; updateTemplate(templateId: string, input: UpdateTemplateInput): Promise<Template>;
/** Deletes a template by id. */ /** Deletes a template by id. */
deleteTemplate(templateId: string): Promise<void>; deleteTemplate(templateId: string): Promise<void>;
/** /**