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:
2026-08-06 15:46:52 +02:00
parent ff7696b724
commit fbaca9d5cc
17 changed files with 514 additions and 34 deletions

View File

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

View File

@ -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()));

View File

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