feat(plugins): seam slash-commands contribuées par plugin via callback — #165 (QA verte)
Permet à un plugin de contribuer des commandes slash adossées à une callback, transitant par le registry/contrat unifié (#162) : - sdk/IdeaSDK: manifeste contributes.slashCommands (déclaratif) — pointer bumpé. - domain: source Plugin étendue + effet PluginCallback (la commande ne s'exécute pas directement : elle renvoie une référence à la callback du plugin). - application: registration des commandes plugin dans le registry + exécution renvoyant l'effet PluginCallback ; métadonnées UI suffisantes pour l'autocomplete. - backend + app-tauri: DTO + commandes Tauri listant/invoquant les commandes plugin. - frontend (contrat): types adapters/domain/ports/mock pour pluginCallback et l'invocation avec arguments. L'exécution effective de la callback côté runtime UI est livrée par #166. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -196,8 +196,9 @@ pub use skill::{
|
||||
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
|
||||
};
|
||||
pub use slash_command::{
|
||||
ExecuteSlashCommand, ExecuteSlashCommandInput, ExecuteSlashCommandOutput, ListSlashCommands,
|
||||
ListSlashCommandsInput, ListSlashCommandsOutput, SlashCommandEffect, SlashCommandRegistry,
|
||||
plugin_slash_commands_from_runtime_catalog, ExecuteSlashCommand, ExecuteSlashCommandInput,
|
||||
ExecuteSlashCommandOutput, ListSlashCommands, ListSlashCommandsInput, ListSlashCommandsOutput,
|
||||
SlashCommandEffect, SlashCommandRegistry,
|
||||
};
|
||||
pub use sprints::{
|
||||
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
||||
|
||||
@ -31,6 +31,8 @@ pub struct PluginContributionSummary {
|
||||
pub top_level_menus: usize,
|
||||
/// Menu item count.
|
||||
pub menu_items: usize,
|
||||
/// Slash-command count.
|
||||
pub slash_commands: usize,
|
||||
/// Layout count.
|
||||
pub layouts: usize,
|
||||
/// MCP server count.
|
||||
@ -42,6 +44,7 @@ impl From<&PluginContributionSet> for PluginContributionSummary {
|
||||
Self {
|
||||
top_level_menus: c.menus.len(),
|
||||
menu_items: c.menu_items.len(),
|
||||
slash_commands: c.slash_commands.len(),
|
||||
layouts: c.layouts.len(),
|
||||
mcp_servers: c.mcp_servers.len(),
|
||||
}
|
||||
@ -3088,6 +3091,8 @@ struct RawContributes {
|
||||
#[serde(default)]
|
||||
menu_items: Vec<RawMenuItem>,
|
||||
#[serde(default)]
|
||||
slash_commands: Vec<RawSlashCommand>,
|
||||
#[serde(default)]
|
||||
layouts: Vec<RawLayout>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<RawMcpServer>,
|
||||
@ -3120,6 +3125,18 @@ struct RawMenuItem {
|
||||
when: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawSlashCommand {
|
||||
name: String,
|
||||
short_description: String,
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
requires_confirmation: bool,
|
||||
#[serde(default)]
|
||||
when: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawLayout {
|
||||
@ -3283,6 +3300,31 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let slash_commands = raw
|
||||
.slash_commands
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
insert(&s.name)?;
|
||||
if !s.name.starts_with('/') {
|
||||
return Err(PluginManifestError::Invalid(
|
||||
"slashCommands[].name must start with '/'".to_owned(),
|
||||
));
|
||||
}
|
||||
if s.short_description.trim().is_empty() {
|
||||
return Err(PluginManifestError::Invalid(
|
||||
"slashCommands[].shortDescription is required".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(domain::PluginSlashCommandContribution {
|
||||
name: s.name,
|
||||
short_description: s.short_description,
|
||||
command: domain::PluginCommandId::new(s.command)
|
||||
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
|
||||
requires_confirmation: s.requires_confirmation,
|
||||
when: s.when,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let layouts = raw
|
||||
.layouts
|
||||
.into_iter()
|
||||
@ -3353,6 +3395,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
|
||||
Ok(PluginContributionSet {
|
||||
menus,
|
||||
menu_items,
|
||||
slash_commands,
|
||||
layouts,
|
||||
mcp_servers,
|
||||
})
|
||||
@ -3423,6 +3466,7 @@ mod tests {
|
||||
"contributes": {
|
||||
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
|
||||
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
|
||||
"slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
|
||||
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
|
||||
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
|
||||
}
|
||||
@ -3745,6 +3789,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(m.contributes.layouts.len(), 1);
|
||||
assert_eq!(m.contributes.mcp_servers.len(), 1);
|
||||
assert_eq!(m.contributes.slash_commands.len(), 1);
|
||||
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
||||
assert!(m.contributes.slash_commands[0].requires_confirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -3809,6 +3856,21 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_slash_command_without_leading_slash() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
value["contributes"]["slashCommands"][0]["name"] = serde_json::json!("graph");
|
||||
assert!(validator()
|
||||
.validate(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
&domain::PluginPackageRef {
|
||||
plugin_id: None,
|
||||
root: "x".into()
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() {
|
||||
for state in [
|
||||
@ -3849,6 +3911,7 @@ mod tests {
|
||||
domain::PluginCapability::Tooling,
|
||||
]
|
||||
);
|
||||
assert_eq!(catalog.plugins[0].contributes.slash_commands.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -4,11 +4,12 @@
|
||||
//! commands without changing the frontend-facing list/filter contract.
|
||||
|
||||
use domain::{
|
||||
filter_slash_commands, native_slash_commands, NativeSlashCommand, SessionId, SlashCommand,
|
||||
SlashCommandAvailability,
|
||||
filter_slash_commands, native_slash_commands, plugin_slash_command, NativeSlashCommand,
|
||||
SessionId, SlashCommand, SlashCommandAvailability, SlashCommandSource,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AppError;
|
||||
use crate::{AppError, PluginRuntimeCatalog};
|
||||
|
||||
/// Lists slash commands.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -31,6 +32,8 @@ pub struct ExecuteSlashCommandInput {
|
||||
pub name: String,
|
||||
/// Current structured chat session, required by conversation-local commands.
|
||||
pub session_id: Option<SessionId>,
|
||||
/// Opaque arguments forwarded to plugin callbacks.
|
||||
pub arguments: Vec<Value>,
|
||||
}
|
||||
|
||||
/// Execution output: application decides intent; adapters perform transport-only
|
||||
@ -61,6 +64,17 @@ pub enum SlashCommandEffect {
|
||||
/// Session whose agent/profile flow is targeted.
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// Dispatch to a plugin-owned callback. The plugin decides what the callback does.
|
||||
PluginCallback {
|
||||
/// Plugin id owning the callback.
|
||||
plugin_id: String,
|
||||
/// SDK command callback id.
|
||||
command_id: String,
|
||||
/// Current structured chat session, when available.
|
||||
session_id: Option<SessionId>,
|
||||
/// Opaque arguments supplied by the caller.
|
||||
arguments: Vec<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// In-memory slash-command registry.
|
||||
@ -78,6 +92,12 @@ impl SlashCommandRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a registry with already-projected plugin commands.
|
||||
#[must_use]
|
||||
pub fn with_plugin_commands(plugin_commands: Vec<SlashCommand>) -> Self {
|
||||
Self { plugin_commands }
|
||||
}
|
||||
|
||||
/// Returns all commands from all sources in deterministic order.
|
||||
#[must_use]
|
||||
pub fn all(&self) -> Vec<SlashCommand> {
|
||||
@ -115,6 +135,18 @@ impl SlashCommandRegistry {
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(plugin) = command.plugin.clone() {
|
||||
return Ok(ExecuteSlashCommandOutput {
|
||||
command,
|
||||
effect: SlashCommandEffect::PluginCallback {
|
||||
plugin_id: plugin.plugin_id,
|
||||
command_id: plugin.command_id,
|
||||
session_id: input.session_id,
|
||||
arguments: input.arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let Some(native) = command.native else {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"slash command {name} has no executable adapter"
|
||||
@ -170,6 +202,16 @@ impl ListSlashCommands {
|
||||
pub fn execute(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput {
|
||||
self.registry.list(input)
|
||||
}
|
||||
|
||||
/// Executes the use case with runtime plugin commands merged into the registry.
|
||||
#[must_use]
|
||||
pub fn execute_with_plugins(
|
||||
&self,
|
||||
input: ListSlashCommandsInput,
|
||||
plugin_commands: Vec<SlashCommand>,
|
||||
) -> ListSlashCommandsOutput {
|
||||
SlashCommandRegistry::with_plugin_commands(plugin_commands).list(input)
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case wrapper for execution planning.
|
||||
@ -192,6 +234,51 @@ impl ExecuteSlashCommand {
|
||||
) -> Result<ExecuteSlashCommandOutput, AppError> {
|
||||
self.registry.execute(input)
|
||||
}
|
||||
|
||||
/// Executes the use case with runtime plugin commands merged into the registry.
|
||||
pub fn execute_with_plugins(
|
||||
&self,
|
||||
input: ExecuteSlashCommandInput,
|
||||
plugin_commands: Vec<SlashCommand>,
|
||||
) -> Result<ExecuteSlashCommandOutput, AppError> {
|
||||
SlashCommandRegistry::with_plugin_commands(plugin_commands).execute(input)
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects active plugin runtime contributions into slash-command metadata.
|
||||
///
|
||||
/// The callback is not executed here. Execution returns [`SlashCommandEffect::PluginCallback`]
|
||||
/// so the host SDK runtime can dispatch the plugin-owned handler.
|
||||
pub fn plugin_slash_commands_from_runtime_catalog(
|
||||
catalog: &PluginRuntimeCatalog,
|
||||
) -> Result<Vec<SlashCommand>, AppError> {
|
||||
let mut commands = Vec::new();
|
||||
for plugin in &catalog.plugins {
|
||||
for slash in &plugin.contributes.slash_commands {
|
||||
let command = plugin_slash_command(
|
||||
plugin.id.clone(),
|
||||
slash.command.as_str().to_owned(),
|
||||
slash.name.clone(),
|
||||
slash.short_description.clone(),
|
||||
slash.requires_confirmation,
|
||||
if slash.when.as_deref().is_some_and(str::is_empty) {
|
||||
SlashCommandAvailability::Unavailable {
|
||||
reason: "Plugin slash command condition is empty".to_owned(),
|
||||
}
|
||||
} else {
|
||||
SlashCommandAvailability::Available
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
if !matches!(command.source, SlashCommandSource::Plugin { .. }) {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin slash command must use plugin source".to_owned(),
|
||||
));
|
||||
}
|
||||
commands.push(command);
|
||||
}
|
||||
}
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -219,6 +306,7 @@ mod tests {
|
||||
.execute(ExecuteSlashCommandInput {
|
||||
name: "help".to_owned(),
|
||||
session_id: None,
|
||||
arguments: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(out.command.name, "/help");
|
||||
@ -235,6 +323,7 @@ mod tests {
|
||||
.execute(ExecuteSlashCommandInput {
|
||||
name: "/clean".to_owned(),
|
||||
session_id: Some(sid),
|
||||
arguments: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@ -250,6 +339,7 @@ mod tests {
|
||||
.execute(ExecuteSlashCommandInput {
|
||||
name: "/profile".to_owned(),
|
||||
session_id: Some(sid),
|
||||
arguments: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@ -257,4 +347,85 @@ mod tests {
|
||||
SlashCommandEffect::ProfileSwitch { session_id: sid }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_command_execution_returns_callback_effect() {
|
||||
let command = plugin_slash_command(
|
||||
"dev.acme.plugin",
|
||||
"dev.acme.plugin.explain",
|
||||
"/explain",
|
||||
"Explain selection",
|
||||
false,
|
||||
SlashCommandAvailability::Available,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
|
||||
.execute_with_plugins(
|
||||
ExecuteSlashCommandInput {
|
||||
name: "/explain".to_owned(),
|
||||
session_id: Some(session()),
|
||||
arguments: vec![serde_json::json!({ "selection": "abc" })],
|
||||
},
|
||||
vec![command],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let SlashCommandEffect::PluginCallback {
|
||||
plugin_id,
|
||||
command_id,
|
||||
session_id,
|
||||
arguments,
|
||||
} = out.effect
|
||||
else {
|
||||
panic!("plugin callback effect expected");
|
||||
};
|
||||
assert_eq!(plugin_id, "dev.acme.plugin");
|
||||
assert_eq!(command_id, "dev.acme.plugin.explain");
|
||||
assert_eq!(session_id, Some(session()));
|
||||
assert_eq!(arguments, vec![serde_json::json!({ "selection": "abc" })]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_catalog_plugin_slash_commands_feed_unified_registry() {
|
||||
let catalog = PluginRuntimeCatalog {
|
||||
plugins: vec![crate::PluginRuntimePlugin {
|
||||
id: "dev.acme.plugin".to_owned(),
|
||||
display_name: "Acme".to_owned(),
|
||||
publisher: None,
|
||||
version: "1.0.0".to_owned(),
|
||||
bundle_url: "idea-plugin://dev.acme.plugin/1.0.0/hash/dist/index.js".to_owned(),
|
||||
icon_url: None,
|
||||
content_hash: "abc".to_owned(),
|
||||
capabilities: vec![domain::PluginCapability::Ui],
|
||||
activation_scope: domain::PluginActivationScope::App,
|
||||
contributes: domain::PluginContributionSet {
|
||||
slash_commands: vec![domain::PluginSlashCommandContribution {
|
||||
name: "/explain".to_owned(),
|
||||
short_description: "Explain selection".to_owned(),
|
||||
command: domain::PluginCommandId::new("dev.acme.plugin.explain").unwrap(),
|
||||
requires_confirmation: true,
|
||||
when: None,
|
||||
}],
|
||||
..domain::PluginContributionSet::default()
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let commands = plugin_slash_commands_from_runtime_catalog(&catalog).unwrap();
|
||||
let listed = ListSlashCommands::new(SlashCommandRegistry::new()).execute_with_plugins(
|
||||
ListSlashCommandsInput {
|
||||
prefix: Some("exp".to_owned()),
|
||||
},
|
||||
commands,
|
||||
);
|
||||
|
||||
assert_eq!(listed.commands.len(), 1);
|
||||
assert_eq!(listed.commands[0].name, "/explain");
|
||||
assert_eq!(
|
||||
listed.commands[0].plugin.as_ref().unwrap().command_id,
|
||||
"dev.acme.plugin.explain"
|
||||
);
|
||||
assert!(listed.commands[0].requires_confirmation);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user