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:
@ -11,6 +11,7 @@ use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
||||
use application::plugin_slash_commands_from_runtime_catalog;
|
||||
use application::{
|
||||
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
||||
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
|
||||
@ -110,17 +111,27 @@ pub fn health(
|
||||
|
||||
/// `list_slash_commands` — list/filter the unified slash-command registry.
|
||||
#[tauri::command]
|
||||
pub fn list_slash_commands(
|
||||
pub async fn list_slash_commands(
|
||||
request: Option<crate::dto::ListSlashCommandsRequestDto>,
|
||||
state: State<'_, AppState>,
|
||||
) -> SlashCommandListDto {
|
||||
) -> Result<SlashCommandListDto, ErrorDto> {
|
||||
let request = request.unwrap_or_default();
|
||||
state
|
||||
let catalog = state
|
||||
.list_plugin_runtime_contributions
|
||||
.execute()
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
let plugin_commands =
|
||||
plugin_slash_commands_from_runtime_catalog(&catalog).map_err(ErrorDto::from)?;
|
||||
Ok(state
|
||||
.list_slash_commands
|
||||
.execute(ListSlashCommandsInput {
|
||||
prefix: request.prefix,
|
||||
})
|
||||
.into()
|
||||
.execute_with_plugins(
|
||||
ListSlashCommandsInput {
|
||||
prefix: request.prefix,
|
||||
},
|
||||
plugin_commands,
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
/// `execute_slash_command` — execute a selected slash command.
|
||||
@ -129,7 +140,7 @@ pub fn list_slash_commands(
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for unavailable commands/missing context,
|
||||
/// `NOT_FOUND` for unknown commands or closed sessions).
|
||||
#[tauri::command]
|
||||
pub fn execute_slash_command(
|
||||
pub async fn execute_slash_command(
|
||||
request: ExecuteSlashCommandRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ExecuteSlashCommandResponseDto, ErrorDto> {
|
||||
@ -138,12 +149,23 @@ pub fn execute_slash_command(
|
||||
.as_deref()
|
||||
.map(parse_session_id)
|
||||
.transpose()?;
|
||||
let catalog = state
|
||||
.list_plugin_runtime_contributions
|
||||
.execute()
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
let plugin_commands =
|
||||
plugin_slash_commands_from_runtime_catalog(&catalog).map_err(ErrorDto::from)?;
|
||||
let output = state
|
||||
.execute_slash_command
|
||||
.execute(ExecuteSlashCommandInput {
|
||||
name: request.name,
|
||||
session_id,
|
||||
})
|
||||
.execute_with_plugins(
|
||||
ExecuteSlashCommandInput {
|
||||
name: request.name,
|
||||
session_id,
|
||||
arguments: request.arguments,
|
||||
},
|
||||
plugin_commands,
|
||||
)
|
||||
.map_err(ErrorDto::from)?;
|
||||
|
||||
let effect = match output.effect {
|
||||
@ -163,6 +185,17 @@ pub fn execute_slash_command(
|
||||
SlashCommandEffect::ProfileSwitch { session_id } => SlashCommandEffectDto::ProfileSwitch {
|
||||
session_id: session_id.to_string(),
|
||||
},
|
||||
SlashCommandEffect::PluginCallback {
|
||||
plugin_id,
|
||||
command_id,
|
||||
session_id,
|
||||
arguments,
|
||||
} => SlashCommandEffectDto::PluginCallback {
|
||||
plugin_id,
|
||||
command_id,
|
||||
session_id: session_id.map(|id| id.to_string()),
|
||||
arguments,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(ExecuteSlashCommandResponseDto {
|
||||
|
||||
@ -14,7 +14,8 @@ use application::{
|
||||
LoadLayoutOutput, OpenTerminalInput, SlashCommandEffect,
|
||||
};
|
||||
use domain::{
|
||||
native_slash_commands, Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView,
|
||||
native_slash_commands, plugin_slash_command, Direction, LayoutNode, LayoutTree, LeafCell,
|
||||
NodeId, PreferredView, SlashCommandAvailability,
|
||||
};
|
||||
|
||||
use application::{AppError, HealthInput};
|
||||
@ -60,9 +61,19 @@ fn health_request_deserialises_camel_case_and_defaults() {
|
||||
|
||||
#[test]
|
||||
fn slash_command_list_dto_serialises_unified_metadata() {
|
||||
let dto = SlashCommandListDto::from(ListSlashCommandsOutput {
|
||||
commands: native_slash_commands(),
|
||||
});
|
||||
let mut commands = native_slash_commands();
|
||||
commands.push(
|
||||
plugin_slash_command(
|
||||
"dev.acme.plugin",
|
||||
"dev.acme.plugin.explain",
|
||||
"/explain",
|
||||
"Explain selection",
|
||||
true,
|
||||
SlashCommandAvailability::Available,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let dto = SlashCommandListDto::from(ListSlashCommandsOutput { commands });
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["commands"][0]["name"], "/help");
|
||||
@ -74,18 +85,32 @@ fn slash_command_list_dto_serialises_unified_metadata() {
|
||||
);
|
||||
assert_eq!(v["commands"][1]["name"], "/clean");
|
||||
assert!(v["commands"][2]["requiresConfirmation"].as_bool().unwrap());
|
||||
assert_eq!(v["commands"][2]["availability"]["status"], "unavailable");
|
||||
assert_eq!(v["commands"][2]["availability"]["status"], "available");
|
||||
assert_eq!(v["commands"][3]["name"], "/explain");
|
||||
assert_eq!(
|
||||
v["commands"][3]["source"],
|
||||
json!({ "plugin": { "pluginId": "dev.acme.plugin" } })
|
||||
);
|
||||
assert_eq!(
|
||||
v["commands"][3]["plugin"]["commandId"],
|
||||
"dev.acme.plugin.explain"
|
||||
);
|
||||
assert!(v.get("requires_confirmation").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_execute_request_deserialises_camelcase_session() {
|
||||
let session_id = Uuid::from_u128(123).to_string();
|
||||
let dto: ExecuteSlashCommandRequestDto =
|
||||
serde_json::from_value(json!({ "name": "clean", "sessionId": session_id })).unwrap();
|
||||
let dto: ExecuteSlashCommandRequestDto = serde_json::from_value(json!({
|
||||
"name": "clean",
|
||||
"sessionId": session_id,
|
||||
"arguments": [{"selection": "abc"}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dto.name, "clean");
|
||||
assert_eq!(dto.session_id.as_deref(), Some(session_id.as_str()));
|
||||
assert_eq!(dto.arguments, vec![json!({ "selection": "abc" })]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -125,6 +150,28 @@ fn slash_command_help_effect_carries_command_metadata() {
|
||||
assert_eq!(v["commands"][1]["name"], "/clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_plugin_callback_effect_serialises_callback_identity() {
|
||||
let dto = SlashCommandEffectDto::PluginCallback {
|
||||
plugin_id: "dev.acme.plugin".to_owned(),
|
||||
command_id: "dev.acme.plugin.explain".to_owned(),
|
||||
session_id: Some(Uuid::from_u128(789).to_string()),
|
||||
arguments: vec![json!({ "selection": "abc" })],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"kind": "pluginCallback",
|
||||
"pluginId": "dev.acme.plugin",
|
||||
"commandId": "dev.acme.plugin.explain",
|
||||
"sessionId": Uuid::from_u128(789).to_string(),
|
||||
"arguments": [{ "selection": "abc" }]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_dto_carries_stable_code_and_message() {
|
||||
let dto = ErrorDto::from(AppError::NotFound("project".into()));
|
||||
|
||||
@ -24,6 +24,7 @@ fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||
contribution_summary: PluginContributionSummaryDto {
|
||||
top_level_menus: 1,
|
||||
menu_items: 2,
|
||||
slash_commands: 3,
|
||||
layouts: 3,
|
||||
mcp_servers: 4,
|
||||
},
|
||||
@ -35,6 +36,7 @@ fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||
assert_eq!(value["lifecycleState"], "pending-disable");
|
||||
assert_eq!(value["trustLevel"], "full");
|
||||
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
||||
assert_eq!(value["contributionSummary"]["slashCommands"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user