diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 7b9a3da..1c7bd76 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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, state: State<'_, AppState>, -) -> SlashCommandListDto { +) -> Result { 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 { @@ -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 { diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index dc419c2..d54a9ab 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -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())); diff --git a/crates/app-tauri/tests/dto_plugins.rs b/crates/app-tauri/tests/dto_plugins.rs index 8e9b9cb..6e2c9e0 100644 --- a/crates/app-tauri/tests/dto_plugins.rs +++ b/crates/app-tauri/tests/dto_plugins.rs @@ -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] diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 73e8084..ca0b8fc 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index d0f3548..ece6864 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -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, #[serde(default)] + slash_commands: Vec, + #[serde(default)] layouts: Vec, #[serde(default)] mcp_servers: Vec, @@ -3120,6 +3125,18 @@ struct RawMenuItem { when: Option, } +#[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, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawLayout { @@ -3283,6 +3300,31 @@ fn validate_contributes(raw: RawContributes) -> Result, _>>()?; + 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::, _>>()?; let layouts = raw .layouts .into_iter() @@ -3353,6 +3395,7 @@ fn validate_contributes(raw: RawContributes) -> Result, + /// Opaque arguments forwarded to plugin callbacks. + pub arguments: Vec, } /// 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, + /// Opaque arguments supplied by the caller. + arguments: Vec, + }, } /// 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) -> Self { + Self { plugin_commands } + } + /// Returns all commands from all sources in deterministic order. #[must_use] pub fn all(&self) -> Vec { @@ -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, + ) -> ListSlashCommandsOutput { + SlashCommandRegistry::with_plugin_commands(plugin_commands).list(input) + } } /// Use case wrapper for execution planning. @@ -192,6 +234,51 @@ impl ExecuteSlashCommand { ) -> Result { 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, + ) -> Result { + 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, 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); + } } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 995ac22..1cfab0a 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -38,6 +38,8 @@ pub struct PluginContributionSummaryDto { pub top_level_menus: usize, /// Menu items count. pub menu_items: usize, + /// Slash commands count. + pub slash_commands: usize, /// Layouts count. pub layouts: usize, /// MCP servers count. @@ -49,6 +51,7 @@ impl From for PluginContributionSummaryD Self { top_level_menus: value.top_level_menus, menu_items: value.menu_items, + slash_commands: value.slash_commands, layouts: value.layouts, mcp_servers: value.mcp_servers, } @@ -803,6 +806,9 @@ pub struct ExecuteSlashCommandRequestDto { /// conversation-local commands. #[serde(default)] pub session_id: Option, + /// Opaque arguments forwarded to plugin callbacks. + #[serde(default)] + pub arguments: Vec, } /// Slash-command execution response DTO. @@ -848,6 +854,21 @@ pub enum SlashCommandEffectDto { #[serde(rename = "sessionId")] session_id: String, }, + /// Dispatch to a plugin-owned SDK command callback. + PluginCallback { + /// Plugin id owning the callback. + #[serde(rename = "pluginId")] + plugin_id: String, + /// SDK command callback id registered by the plugin. + #[serde(rename = "commandId")] + command_id: String, + /// Current structured chat session, when available. + #[serde(default, rename = "sessionId", skip_serializing_if = "Option::is_none")] + session_id: Option, + /// Opaque arguments supplied by the caller. + #[serde(default)] + arguments: Vec, + }, } impl From for SlashCommandEffectDto { @@ -861,6 +882,17 @@ impl From for SlashCommandEffectDto { SlashCommandEffect::ProfileSwitch { session_id } => Self::ProfileSwitch { session_id: session_id.to_string(), }, + SlashCommandEffect::PluginCallback { + plugin_id, + command_id, + session_id, + arguments, + } => Self::PluginCallback { + plugin_id, + command_id, + session_id: session_id.map(|id| id.to_string()), + arguments, + }, } } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index ff0e1df..cd16879 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -110,8 +110,8 @@ pub use chat_attachment::{ pub use skill::{Skill, SkillKind, SkillRef, SkillScope}; pub use slash_command::{ - filter_slash_commands, native_slash_commands, NativeSlashCommand, SlashCommand, - SlashCommandAvailability, SlashCommandSource, + filter_slash_commands, native_slash_commands, plugin_slash_command, NativeSlashCommand, + PluginSlashCommandCallback, SlashCommand, SlashCommandAvailability, SlashCommandSource, }; pub use template::{AgentTemplate, TemplateVersion}; @@ -234,8 +234,9 @@ pub use plugin::{ PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, - PluginRegistry, PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, - PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage, + PluginRegistry, PluginRegistryEntry, PluginSlashCommandContribution, + PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, + StagedPluginPackage, }; pub use sandbox::{ diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index 7b49d05..6508183 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -411,6 +411,24 @@ pub struct PluginMenuItemContribution { pub when: Option, } +/// Slash-command contribution backed by a plugin command callback. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginSlashCommandContribution { + /// Slash name, including the leading `/`. + pub name: String, + /// Short autocomplete/help description. + pub short_description: String, + /// Plugin command callback id registered through the SDK command registry. + pub command: PluginCommandId, + /// Whether the host should ask for confirmation before dispatching callback. + #[serde(default)] + pub requires_confirmation: bool, + /// Optional declarative condition reserved for host-side availability. + #[serde(default)] + pub when: Option, +} + /// Layout contribution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -472,6 +490,9 @@ pub struct PluginContributionSet { /// Menu items. #[serde(default)] pub menu_items: Vec, + /// Slash commands. + #[serde(default)] + pub slash_commands: Vec, /// Layout contributions. #[serde(default)] pub layouts: Vec, diff --git a/crates/domain/src/slash_command.rs b/crates/domain/src/slash_command.rs index dba1911..bb3632c 100644 --- a/crates/domain/src/slash_command.rs +++ b/crates/domain/src/slash_command.rs @@ -17,6 +17,7 @@ pub enum SlashCommandSource { /// Contributed by a plugin. Plugin { /// Opaque plugin id. + #[serde(rename = "pluginId")] plugin_id: String, }, } @@ -54,6 +55,16 @@ pub enum NativeSlashCommand { Profile, } +/// Plugin callback identity for a contributed slash command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginSlashCommandCallback { + /// Opaque plugin id. + pub plugin_id: String, + /// SDK command callback id registered by the plugin. + pub command_id: String, +} + /// UI-facing slash-command metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -71,6 +82,9 @@ pub struct SlashCommand { /// Built-in command discriminator when `source == native`. #[serde(default, skip_serializing_if = "Option::is_none")] pub native: Option, + /// Plugin callback identity when `source == plugin`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin: Option, } impl SlashCommand { @@ -86,6 +100,7 @@ impl SlashCommand { availability: SlashCommandAvailability, source: SlashCommandSource, native: Option, + plugin: Option, ) -> Result { let name = name.into(); let short_description = short_description.into(); @@ -103,6 +118,7 @@ impl SlashCommand { availability, source, native, + plugin, }) } } @@ -118,6 +134,7 @@ pub fn native_slash_commands() -> Vec { SlashCommandAvailability::Available, SlashCommandSource::Native, Some(NativeSlashCommand::Help), + None, ) .expect("valid native slash command"), SlashCommand::new( @@ -127,6 +144,7 @@ pub fn native_slash_commands() -> Vec { SlashCommandAvailability::Available, SlashCommandSource::Native, Some(NativeSlashCommand::Clean), + None, ) .expect("valid native slash command"), SlashCommand::new( @@ -136,11 +154,41 @@ pub fn native_slash_commands() -> Vec { SlashCommandAvailability::Available, SlashCommandSource::Native, Some(NativeSlashCommand::Profile), + None, ) .expect("valid native slash command"), ] } +/// Builds validated metadata for a plugin-contributed slash command. +pub fn plugin_slash_command( + plugin_id: impl Into, + command_id: impl Into, + name: impl Into, + short_description: impl Into, + requires_confirmation: bool, + availability: SlashCommandAvailability, +) -> Result { + let plugin_id = plugin_id.into(); + let command_id = command_id.into(); + crate::validation::non_empty(&plugin_id, "slash_command.plugin_id")?; + crate::validation::non_empty(&command_id, "slash_command.command_id")?; + SlashCommand::new( + name, + short_description, + requires_confirmation, + availability, + SlashCommandSource::Plugin { + plugin_id: plugin_id.clone(), + }, + None, + Some(PluginSlashCommandCallback { + plugin_id, + command_id, + }), + ) +} + /// Filters commands by a user-entered slash prefix. /// /// Blank prefixes return every command. Prefixes without a leading `/` are @@ -180,6 +228,7 @@ mod tests { assert!(commands[1].availability.is_available()); assert!(commands[2].availability.is_available()); assert!(commands[2].requires_confirmation); + assert!(commands.iter().all(|c| c.plugin.is_none())); } #[test] @@ -196,4 +245,24 @@ mod tests { .collect::>(); assert_eq!(names, vec!["/profile"]); } + + #[test] + fn plugin_command_carries_callback_identity() { + let command = plugin_slash_command( + "dev.acme.plugin", + "dev.acme.plugin.explain", + "/explain", + "Explain selection", + true, + SlashCommandAvailability::Available, + ) + .unwrap(); + + assert_eq!(command.name, "/explain"); + assert!(command.native.is_none()); + assert_eq!( + command.plugin.unwrap().command_id, + "dev.acme.plugin.explain" + ); + } } diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index 01cf70f..2d79a15 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -165,6 +165,7 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { assert_eq!(result.plugin.display_name, "Hello Plugin"); assert_eq!(result.review.contribution_summary.top_level_menus, 1); assert_eq!(result.review.contribution_summary.menu_items, 1); + assert_eq!(result.review.contribution_summary.slash_commands, 1); assert_eq!(result.review.contribution_summary.layouts, 1); assert!(app_data .join("plugins/installed/com.example.hello-plugin/dist/index.js") @@ -187,6 +188,11 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { plugin.contributes.menu_items[0].command.as_str(), "hello-plugin" ); + assert_eq!(plugin.contributes.slash_commands[0].name, "/hello"); + assert_eq!( + plugin.contributes.slash_commands[0].command.as_str(), + "hello-plugin" + ); assert_eq!( plugin.contributes.layouts[0].layout_type.as_str(), "hello-plugin.hello-world" diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index bd39cf2..13769cf 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -248,7 +248,7 @@ describe("TauriAgentGateway invoke payloads", () => { }); expect(invoke).toHaveBeenCalledWith("execute_slash_command", { - request: { name: "/clean", sessionId: "chat-session-1" }, + request: { name: "/clean", sessionId: "chat-session-1", arguments: [] }, }); expect(out.effect.kind).toBe("cleanConversation"); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 78189a3..73ef629 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -286,12 +286,13 @@ export class TauriAgentGateway implements AgentGateway { async executeSlashCommand( name: string, - options: { sessionId?: string | null } = {}, + options: { sessionId?: string | null; arguments?: unknown[] } = {}, ): Promise { return invoke("execute_slash_command", { request: { name, sessionId: options.sessionId ?? null, + arguments: options.arguments ?? [], }, }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 100065f..0276aec 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -906,7 +906,7 @@ export class MockAgentGateway implements AgentGateway { async executeSlashCommand( name: string, - options: { sessionId?: string | null } = {}, + options: { sessionId?: string | null; arguments?: unknown[] } = {}, ): Promise { const normalized = name.trim().startsWith("/") ? name.trim() : `/${name.trim()}`; const command = this.slashCommands.find((item) => item.name === normalized); @@ -951,6 +951,18 @@ export class MockAgentGateway implements AgentGateway { }, }; } + if (command.plugin) { + return { + command: structuredClone(command), + effect: { + kind: "pluginCallback", + pluginId: command.plugin.pluginId, + commandId: command.plugin.commandId, + sessionId: options.sessionId ?? undefined, + arguments: structuredClone(options.arguments ?? []), + }, + }; + } return { command: structuredClone(command), effect: { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 0157f5d..a676a89 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1698,12 +1698,23 @@ export interface SlashCommand { availability: SlashCommandAvailability; source: SlashCommandSource; native?: NativeSlashCommand; + plugin?: { + pluginId: string; + commandId: string; + }; } export type SlashCommandEffect = | { kind: "help"; commands: SlashCommand[] } | { kind: "cleanConversation"; sessionId: string; clearedChunks: number } - | { kind: "profileSwitch"; sessionId: string }; + | { kind: "profileSwitch"; sessionId: string } + | { + kind: "pluginCallback"; + pluginId: string; + commandId: string; + sessionId?: string; + arguments: unknown[]; + }; export interface ExecuteSlashCommandResult { command: SlashCommand; @@ -1844,6 +1855,7 @@ export interface PluginUninstallResult { export interface PluginContributionDto { menus: PluginTopLevelMenuContribution[]; menuItems: PluginMenuItemContribution[]; + slashCommands?: PluginSlashCommandContribution[]; layouts: PluginLayoutContribution[]; mcpServers: PluginMcpServerSummary[]; } @@ -2125,6 +2137,15 @@ export interface PluginMenuItemContribution { when?: string; } +/** Manifest declaration of a plugin-backed slash command. */ +export interface PluginSlashCommandContribution { + name: string; + shortDescription: string; + command: string; + requiresConfirmation?: boolean; + when?: string; +} + /** A menu item resolved for rendering — enablement already evaluated (carnet §7.2). */ export interface ResolvedPluginMenuItem { id: string; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index dc61888..97a4ba7 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -4,6 +4,13 @@ import { describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import type { AgentProfile } from "@/domain"; +import { PluginRuntimeProvider } from "@/features/plugins"; +import { + PluginCommandRegistry, + PluginLayoutRegistry, + PluginMenuRegistry, + PluginRuntimeRegistry, +} from "@/plugins/runtime"; import type { Gateways } from "@/ports"; import { CustomAgentChatView } from "./CustomAgentChatView"; @@ -18,6 +25,27 @@ const profile: AgentProfile = { structuredAdapter: "codex", }; +function addPluginCommand( + registry: PluginRuntimeRegistry, + pluginId: string, + commandId: string, + handler: (...args: unknown[]) => unknown | Promise, +) { + const commands = new PluginCommandRegistry(pluginId, new Set([commandId])); + commands.register(commandId, handler); + registry.add({ + pluginId, + displayName: "Acme Plugin", + contributes: { + commands: [{ id: commandId, title: "Explain", shortDescription: "Explain selection" }], + }, + commands, + layouts: new PluginLayoutRegistry(pluginId, new Set()), + menu: new PluginMenuRegistry(pluginId), + dispose: async () => {}, + }); +} + describe("CustomAgentChatView", () => { it("cancels only the current turn and keeps the structured session alive", async () => { const agent = { @@ -522,6 +550,173 @@ describe("CustomAgentChatView", () => { expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull(); }); + it("dispatches plugin slash-command callback effects through the plugin runtime", async () => { + const commandHandler = vi.fn(async () => {}); + const registry = new PluginRuntimeRegistry(); + addPluginCommand( + registry, + "dev.acme.explain", + "dev.acme.explain.run", + commandHandler, + ); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async (_name: string, options) => ({ + command: { + name: "/explain", + shortDescription: "Explain selection", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: { plugin: { pluginId: "dev.acme.explain" } }, + plugin: { + pluginId: "dev.acme.explain", + commandId: "dev.acme.explain.run", + }, + }, + effect: { + kind: "pluginCallback" as const, + pluginId: "dev.acme.explain", + commandId: "dev.acme.explain.run", + sessionId: options.sessionId, + arguments: options.arguments ?? [], + }, + })), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: "/explain focus this" } }); + fireEvent.keyDown(composer, { key: "Enter" }); + + await waitFor(() => + expect(agent.executeSlashCommand).toHaveBeenCalledWith("/explain", { + sessionId: "chat-session-1", + arguments: ["focus this"], + }), + ); + await waitFor(() => expect(commandHandler).toHaveBeenCalledWith("focus this")); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + expect( + await screen.findByText("Commande plugin /explain exécutée."), + ).toBeTruthy(); + expect(composer.value).toBe(""); + }); + + it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => { + const registry = new PluginRuntimeRegistry(); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async () => ({ + command: { + name: "/explain", + shortDescription: "Explain selection", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: { plugin: { pluginId: "dev.acme.missing" } }, + plugin: { + pluginId: "dev.acme.missing", + commandId: "dev.acme.missing.run", + }, + }, + effect: { + kind: "pluginCallback" as const, + pluginId: "dev.acme.missing", + commandId: "dev.acme.missing.run", + arguments: [], + }, + })), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + const composer = screen.getByLabelText(/message CLI custom/); + fireEvent.change(composer, { target: { value: "/explain" } }); + fireEvent.keyDown(composer, { key: "Enter" }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Commande /explain échouée"); + expect(alert.textContent).toContain('plugin "dev.acme.missing" is not loaded'); + expect(screen.getAllByText(/Commande \/explain échouée/)).toHaveLength(2); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + }); + it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 9406eb7..c5d7797 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -26,6 +26,7 @@ import type { SlashCommand, } from "@/domain"; import { useGateways } from "@/app/di"; +import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { Button, Spinner, cn } from "@/shared"; import type { ChatAttachmentInput } from "@/ports"; @@ -66,6 +67,11 @@ interface ProfileCommandDialogState { error: string | null; } +interface SlashCommandInvocation { + name: string; + arguments: unknown[]; +} + function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); @@ -274,6 +280,17 @@ function slashCommandPrefix(draft: string): string | null { return draft; } +function slashCommandInvocation(text: string): SlashCommandInvocation | null { + if (!text.startsWith("/")) return null; + const [name, ...argumentParts] = text.split(/\s+/); + if (!name) return null; + const rawArguments = argumentParts.join(" ").trim(); + return { + name, + arguments: rawArguments ? [rawArguments] : [], + }; +} + function isSlashCommandAvailable(command: SlashCommand): boolean { return command.availability.status === "available"; } @@ -328,6 +345,7 @@ export function CustomAgentChatView({ onConversationId, }: CustomAgentChatViewProps) { const { agent, profile: profileGateway, system } = useGateways(); + const pluginRuntime = usePluginRuntime(); const [turns, setTurns] = useState([]); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); @@ -756,6 +774,45 @@ export function CustomAgentChatView({ } } + async function executeSlashCommand(invocation: SlashCommandInvocation) { + if (!agent.executeSlashCommand) { + setError(`Commande ${invocation.name} indisponible dans ce runtime.`); + return; + } + setDraft(""); + setError(null); + try { + const sid = + currentSession ?? + (await recoverStructuredSession({ + applyScrollback: false, + retryAttachNotFound: true, + })); + const result = await agent.executeSlashCommand(invocation.name, { + sessionId: sid, + arguments: invocation.arguments, + }); + if (result.effect.kind !== "pluginCallback") { + throw new Error( + `La commande ${invocation.name} n'a pas renvoyé d'effet pluginCallback.`, + ); + } + await pluginRuntime.registry.runCommandStrict( + result.effect.pluginId, + result.effect.commandId, + ...result.effect.arguments, + ); + setTurns((prev) => [ + ...prev, + { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, + ]); + } catch (e) { + const message = `Commande ${invocation.name} échouée: ${describe(e)}`; + setError(message); + setTurns((prev) => [...prev, { role: "error", text: message }]); + } + } + async function pickAttachment() { const path = await system.pickFile(); if (path) { @@ -804,6 +861,11 @@ export function CustomAgentChatView({ await openProfileCommandFlow(); return; } + const slashInvocation = slashCommandInvocation(text); + if (slashInvocation) { + await executeSlashCommand(slashInvocation); + return; + } const outgoingAttachments = attachments; const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentLabels = outgoingAttachments.map((item) => item.label); diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts index 86b3440..e667d37 100644 --- a/frontend/src/features/plugins/usePluginMenus.ts +++ b/frontend/src/features/plugins/usePluginMenus.ts @@ -23,7 +23,7 @@ export interface UsePluginMenusResult { topLevelMenus: MenuBarMenu[]; /** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */ itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[]; - runCommand: (pluginId: string, commandId: string) => Promise; + runCommand: (pluginId: string, commandId: string) => Promise; } function describeError(e: unknown): string { diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index ea956a1..90a2382 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -98,7 +98,7 @@ describe("loadPlugins", () => { ); expect(failures).toEqual([]); expect((globalThis as Record).__registerError).toMatch( - /not declared by any menu item/, + /not declared by any command contribution/, ); }); @@ -591,6 +591,13 @@ describe("loadPlugins", () => { command: "hello-plugin", }, ], + slashCommands: [ + { + name: "/hello", + shortDescription: "Run the hello-plugin callback", + command: "hello-plugin", + }, + ], layouts: [ { type: "hello-plugin.hello-world", @@ -613,6 +620,13 @@ describe("loadPlugins", () => { }, ]); expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]); + expect(registry.get("com.example.hello-plugin")?.contributes.slashCommands).toEqual([ + { + name: "/hello", + shortDescription: "Run the hello-plugin callback", + command: "hello-plugin", + }, + ]); await registry.runCommand("com.example.hello-plugin", "hello-plugin"); expect((globalThis as Record).__helloArchiveCommandRan).toBe(true); const Layout = registry.layoutComponent( @@ -623,6 +637,39 @@ describe("loadPlugins", () => { expect((Layout as unknown as () => string)()).toBe("hello-world"); }); + it("allows command callbacks declared only by slashCommands", async () => { + const bundle = dataUrl(` + export function activate(ctx) { + ctx.commands.registerCommand("dev.acme.explain", () => { + globalThis.__slashOnlyCommandRan = true; + }); + } + `); + const { registry, failures } = await loadPlugins( + [ + entry({ + id: "dev.acme.slash", + displayName: "Slash", + bundleUrl: bundle, + contributes: { + slashCommands: [ + { + name: "/explain", + shortDescription: "Explain selection", + command: "dev.acme.explain", + }, + ], + } as unknown as PluginContributionDto, + }), + ], + gateways, + ); + + expect(failures).toEqual([]); + await registry.runCommand("dev.acme.slash", "dev.acme.explain"); + expect((globalThis as Record).__slashOnlyCommandRan).toBe(true); + }); + it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => { const bundle = dataUrl(` export function activate(ctx) { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index a8ba952..50ec18f 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -151,7 +151,7 @@ function createCommandContext(commands: PluginCommandRegistry): PluginCommandCon register: (commandId, handler) => commands.register(commandId, handler), registerCommand: (commandId, handler) => commands.register(commandId, async (...args) => { - await handler(...args); + return await handler(...args); }), }; } @@ -195,7 +195,10 @@ function safePluginId(entry: unknown): string { function commandIdsFromContributes(contributes: PluginContributionDto): Set { return new Set( - contributes.menuItems.flatMap((item) => { + [ + ...arrayOrEmpty(contributes.menuItems), + ...arrayOrEmpty(contributes.slashCommands), + ].flatMap((item) => { const command = nonEmptyString(objectOrEmpty(item).command); return command ? [command] : []; }), @@ -216,6 +219,7 @@ function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto return { menus: arrayOrEmpty(contributes?.menus), menuItems: arrayOrEmpty(contributes?.menuItems), + slashCommands: arrayOrEmpty(contributes?.slashCommands), layouts: arrayOrEmpty(contributes?.layouts), mcpServers: arrayOrEmpty(contributes?.mcpServers), }; diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index fe996ac..f51fb92 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -59,7 +59,7 @@ export interface Disposable { dispose(): void; } -export type PluginCommandHandler = (...args: unknown[]) => void | Promise; +export type PluginCommandHandler = (...args: unknown[]) => unknown | Promise; /** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */ export class PluginCommandRegistry { @@ -74,7 +74,7 @@ export class PluginCommandRegistry { if (!this.declaredCommandIds.has(commandId)) { throw new Error( `plugin "${this.pluginId}" tried to register command "${commandId}" ` + - "which is not declared by any menu item in its manifest", + "which is not declared by any command contribution in its manifest", ); } this.handlers.set(commandId, handler); @@ -86,11 +86,11 @@ export class PluginCommandRegistry { } /** Runs a registered command; a no-op (never throws) if none is registered. */ - async run(commandId: string, ...args: unknown[]): Promise { + async run(commandId: string, ...args: unknown[]): Promise { const handler = this.handlers.get(commandId); if (!handler) return; try { - await handler(...args); + return await handler(...args); } catch (e) { console.error( `[plugin:${this.pluginId}] command "${commandId}" failed`, @@ -99,6 +99,17 @@ export class PluginCommandRegistry { } } + /** Runs a registered command and surfaces failures to the caller. */ + async runStrict(commandId: string, ...args: unknown[]): Promise { + const handler = this.handlers.get(commandId); + if (!handler) { + throw new Error( + `plugin "${this.pluginId}" has no registered command handler "${commandId}"`, + ); + } + return await handler(...args); + } + has(commandId: string): boolean { return this.handlers.has(commandId); } @@ -256,7 +267,19 @@ export class PluginRuntimeRegistry { ); } - async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise { - await this.loaded.get(pluginId)?.commands.run(commandId, ...args); + async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise { + return await this.loaded.get(pluginId)?.commands.run(commandId, ...args); + } + + async runCommandStrict( + pluginId: string, + commandId: string, + ...args: unknown[] + ): Promise { + const plugin = this.loaded.get(pluginId); + if (!plugin) { + throw new Error(`plugin "${pluginId}" is not loaded`); + } + return await plugin.commands.runStrict(commandId, ...args); } } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 43c371c..607127a 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -315,7 +315,7 @@ export interface AgentGateway { /** Executes a selected slash command and returns the planned UI effect. */ executeSlashCommand?( name: string, - options?: { sessionId?: string | null }, + options?: { sessionId?: string | null; arguments?: unknown[] }, ): Promise; /** Interrupts only the current turn of a live structured session. */ cancelAgentChat?(sessionId: string): Promise; diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index fe50219..d1c3c00 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit fe50219493c2d37c488f35d825d11c5ba6929db2 +Subproject commit d1c3c00b4dbc74953f032e4756d527d9dab4a8ec