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:
@ -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