diff --git a/Cargo.lock b/Cargo.lock index 1f5dc28..1deb921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,7 +960,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openab" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "async-trait", diff --git a/docs/kiro.md b/docs/kiro.md index dbf5697..22e1d96 100644 --- a/docs/kiro.md +++ b/docs/kiro.md @@ -50,3 +50,39 @@ kubectl rollout restart deployment/openab-kiro |------|----------| | `~/.kiro/` | Settings, skills, sessions | | `~/.local/share/kiro-cli/` | OAuth tokens (`data.sqlite3` → `auth_kv` table), conversation history | + +## Slash Commands + +| Command | Purpose | Status | +|---------|---------|--------| +| `/models` | Switch AI model | ✅ Implemented | +| `/agents` | Switch agent mode | ✅ Implemented | +| `/cancel` | Cancel current generation | ✅ Implemented | + +### `/models` — Switch AI Model + +Kiro CLI returns available models via ACP `configOptions` (category: `"model"`) on session creation. User types `/models` in a thread → select menu appears → pick a model → OpenAB sends `session/set_config_option` (falls back to `/model ` prompt if not supported). + +### `/agents` — Switch Agent Mode + +Same mechanism as `/models` but for the `agent` category. Kiro CLI exposes modes like `kiro_default` and `kiro_planner` via `configOptions`. + +### `/cancel` — Cancel Current Operation + +Sends a `session/cancel` JSON-RPC notification to abort in-flight LLM requests and tool calls. Works immediately — no need to wait for the current response to finish. + +**Note:** All slash commands only work in threads where a conversation is already active. If no session exists, they will prompt the user to start one first. + +See [docs/slash-commands.md](slash-commands.md) for full details. + +## Built-in Kiro CLI Commands + +All built-in kiro-cli slash commands can be passed directly after an @mention: + +``` +@MyBot /compact +@MyBot /clear +@MyBot /model claude-sonnet-4 +``` + +These are forwarded as-is to the kiro-cli ACP session as a prompt. Any command that kiro-cli supports in its interactive mode works here. diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 879a398..9cdd60b 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -1,4 +1,4 @@ -use crate::acp::protocol::{JsonRpcMessage, JsonRpcRequest, JsonRpcResponse}; +use crate::acp::protocol::{ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, parse_config_options}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -115,6 +115,7 @@ pub struct AcpConnection { notify_tx: Arc>>>, pub acp_session_id: Option, pub supports_load_session: bool, + pub config_options: Vec, pub last_active: Instant, pub session_reset: bool, _reader_handle: JoinHandle<()>, @@ -268,6 +269,7 @@ impl AcpConnection { notify_tx, acp_session_id: None, supports_load_session: false, + config_options: Vec::new(), last_active: Instant::now(), session_reset: false, _reader_handle: reader_handle, @@ -278,7 +280,7 @@ impl AcpConnection { self.next_id.fetch_add(1, Ordering::Relaxed) } - async fn send_raw(&self, data: &str) -> Result<()> { + pub(crate) async fn send_raw(&self, data: &str) -> Result<()> { debug!(data = data.trim(), "acp_send"); let mut w = self.stdin.lock().await; w.write_all(data.as_bytes()).await?; @@ -352,9 +354,66 @@ impl AcpConnection { info!(session_id = %session_id, "session created"); self.acp_session_id = Some(session_id.clone()); + if let Some(result) = resp.result.as_ref() { + self.config_options = parse_config_options(result); + if !self.config_options.is_empty() { + info!(count = self.config_options.len(), "parsed configOptions"); + } + } Ok(session_id) } + /// Set a config option (e.g. model, mode) via ACP session/set_config_option. + /// Returns the updated list of all config options. + pub async fn set_config_option(&mut self, config_id: &str, value: &str) -> Result> { + let session_id = self + .acp_session_id + .as_ref() + .ok_or_else(|| anyhow!("no session"))? + .clone(); + + let resp = self + .send_request( + "session/set_config_option", + Some(json!({ + "sessionId": session_id, + "configId": config_id, + "value": value, + })), + ) + .await; + + match resp { + Ok(r) => { + if let Some(result) = r.result.as_ref() { + self.config_options = parse_config_options(result); + } + info!(config_id, value, "config option set"); + } + Err(_) => { + // Fall back: send as a slash command (e.g. "/model claude-sonnet-4") + let cmd = format!("/{config_id} {value}"); + info!(cmd, "set_config_option not supported, falling back to prompt"); + let _resp = self + .send_request( + "session/prompt", + Some(json!({ + "sessionId": session_id, + "prompt": [{"type": "text", "text": cmd}], + })), + ) + .await?; + for opt in &mut self.config_options { + if opt.id == config_id { + opt.current_value = value.to_string(); + } + } + } + } + + Ok(self.config_options.clone()) + } + /// Send a prompt with content blocks (text and/or images) and return a receiver /// for streaming notifications. The final message on the channel will have id set /// (the prompt response). @@ -403,6 +462,11 @@ impl AcpConnection { self.last_active = Instant::now(); } + /// Return a clone of the stdin handle for lock-free cancel. + pub fn cancel_handle(&self) -> Arc> { + Arc::clone(&self.stdin) + } + pub fn alive(&self) -> bool { !self._reader_handle.is_finished() } @@ -422,6 +486,9 @@ impl AcpConnection { } info!(session_id, "session loaded"); self.acp_session_id = Some(session_id.to_string()); + if let Some(result) = resp.result.as_ref() { + self.config_options = parse_config_options(result); + } Ok(()) } diff --git a/src/acp/pool.rs b/src/acp/pool.rs index 0c7dc93..e1d27bf 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -1,4 +1,5 @@ use crate::acp::connection::AcpConnection; +use crate::acp::protocol::ConfigOption; use crate::config::AgentConfig; use anyhow::{anyhow, Result}; use std::collections::HashMap; @@ -12,6 +13,9 @@ use tracing::{info, warn}; struct PoolState { /// Active connections: thread_key → AcpConnection handle. active: HashMap>>, + /// Lock-free cancel handles: thread_key → (stdin, session_id). + /// Stored separately so cancel can work without locking the connection. + cancel_handles: HashMap>, String)>, /// Suspended sessions: thread_key → ACP sessionId. /// Saved on eviction so sessions can be resumed via `session/load`. suspended: HashMap, @@ -62,6 +66,7 @@ impl SessionPool { Self { state: RwLock::new(PoolState { active: HashMap::new(), + cancel_handles: HashMap::new(), suspended: HashMap::new(), creating: HashMap::new(), }), @@ -163,6 +168,8 @@ impl SessionPool { } } + let cancel_handle = new_conn.cancel_handle(); + let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default(); let new_conn = Arc::new(Mutex::new(new_conn)); let mut state = self.state.write().await; @@ -206,6 +213,9 @@ impl SessionPool { state.suspended.remove(thread_id); state.active.insert(thread_id.to_string(), new_conn); + if !cancel_session_id.is_empty() { + state.cancel_handles.insert(thread_id.to_string(), (cancel_handle, cancel_session_id)); + } Ok(()) } @@ -229,6 +239,59 @@ impl SessionPool { f(&mut conn).await } + /// Get cached configOptions for a session (e.g. available models). + pub async fn get_config_options(&self, thread_id: &str) -> Vec { + let state = self.state.read().await; + let conn = match state.active.get(thread_id) { + Some(c) => c.clone(), + None => return Vec::new(), + }; + drop(state); + let conn = conn.lock().await; + conn.config_options.clone() + } + + /// Set a config option (e.g. model) via ACP and return updated options. + pub async fn set_config_option( + &self, + thread_id: &str, + config_id: &str, + value: &str, + ) -> Result> { + let conn = { + let state = self.state.read().await; + state + .active + .get(thread_id) + .cloned() + .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + }; + let mut conn = conn.lock().await; + conn.set_config_option(config_id, value).await + } + + /// Cancel the current in-flight operation for a session. + /// Uses pre-stored cancel handles to avoid locking the connection (which is held during streaming). + pub async fn cancel_session(&self, thread_id: &str) -> Result<()> { + let (stdin, session_id) = { + let state = self.state.read().await; + state.cancel_handles.get(thread_id).cloned() + .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? + }; + let data = serde_json::to_string(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": {"sessionId": session_id} + }))?; + tracing::info!(session_id, "sending session/cancel"); + use tokio::io::AsyncWriteExt; + let mut w = stdin.lock().await; + w.write_all(data.as_bytes()).await?; + w.write_all(b"\n").await?; + w.flush().await?; + Ok(()) + } + pub async fn cleanup_idle(&self, ttl_secs: u64) { let cutoff = Instant::now() - std::time::Duration::from_secs(ttl_secs); diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 82f00eb..5860685 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -54,6 +54,107 @@ impl std::fmt::Display for JsonRpcError { } } +// --- ACP configOptions (session-level configuration) --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigOptionValue { + pub value: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigOption { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(rename = "type")] + pub option_type: String, + pub current_value: String, + pub options: Vec, +} + +/// Extract configOptions from a JSON-RPC result value. +/// Supports standard `configOptions` and kiro-cli's `models`/`modes` fallback. +pub fn parse_config_options(result: &Value) -> Vec { + if let Some(opts) = result + .get("configOptions") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + { + if !opts.is_empty() { + return opts; + } + } + + // Kiro-cli fallback: parse models/modes format + let mut options = Vec::new(); + + if let Some(models) = result.get("models") { + let current = models.get("currentModelId").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) { + let values: Vec = available + .iter() + .filter_map(|m| { + let id = m.get("modelId").or_else(|| m.get("id")).and_then(|v| v.as_str())?; + let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); + Some(ConfigOptionValue { + value: id.to_string(), + name: name.to_string(), + description: m.get("description").and_then(|v| v.as_str()).map(String::from), + }) + }) + .collect(); + if !values.is_empty() { + options.push(ConfigOption { + id: "model".to_string(), + name: "Model".to_string(), + description: Some("AI model selection".to_string()), + category: Some("model".to_string()), + option_type: "enum".to_string(), + current_value: current.to_string(), + options: values, + }); + } + } + } + + if let Some(modes) = result.get("modes") { + let current = modes.get("currentModeId").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(available) = modes.get("availableModes").and_then(|v| v.as_array()) { + let values: Vec = available + .iter() + .filter_map(|m| { + let id = m.get("id").and_then(|v| v.as_str())?; + let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); + Some(ConfigOptionValue { + value: id.to_string(), + name: name.to_string(), + description: m.get("description").and_then(|v| v.as_str()).map(String::from), + }) + }) + .collect(); + if !values.is_empty() { + options.push(ConfigOption { + id: "agent".to_string(), + name: "Agent".to_string(), + description: Some("Agent mode selection".to_string()), + category: Some("agent".to_string()), + option_type: "enum".to_string(), + current_value: current.to_string(), + options: values, + }); + } + } + } + + options +} + // --- ACP notification classification --- #[derive(Debug)] @@ -62,6 +163,7 @@ pub enum AcpEvent { Thinking, ToolStart { id: String, title: String }, ToolDone { id: String, title: String, status: String }, + ConfigUpdate { options: Vec }, Status, } @@ -105,6 +207,10 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { } } "plan" => Some(AcpEvent::Status), + "config_option_update" => { + let options = parse_config_options(update); + Some(AcpEvent::ConfigUpdate { options }) + } _ => None, } } diff --git a/src/adapter.rs b/src/adapter.rs index 3f9e170..189e98c 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -103,6 +103,11 @@ impl AdapterRouter { } } + /// Access the underlying session pool (e.g. for config option queries). + pub fn pool(&self) -> &Arc { + &self.pool + } + /// Handle an incoming user message. The adapter is responsible for /// filtering, resolving the thread, and building the SenderContext. /// This method handles sender context injection, session management, and streaming. @@ -341,6 +346,9 @@ impl AdapterRouter { let _ = tx.send(compose_display(&tool_lines, &text_buf, true)); } } + AcpEvent::ConfigUpdate { options } => { + conn.config_options = options; + } _ => {} } } diff --git a/src/discord.rs b/src/discord.rs index 027967f..ac94759 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,12 +1,14 @@ use crate::acp::ContentBlock; +use crate::acp::protocol::ConfigOption; use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::format; use crate::media; use async_trait::async_trait; use std::sync::LazyLock; -use serenity::builder::CreateThread; +use serenity::builder::{CreateActionRow, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread}; use serenity::http::Http; +use serenity::model::application::{ComponentInteractionDataKind, Interaction}; use serenity::model::channel::{AutoArchiveDuration, Message, ReactionType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; @@ -523,8 +525,198 @@ impl EventHandler for Handler { }); } - async fn ready(&self, _ctx: Context, ready: Ready) { + async fn ready(&self, ctx: Context, ready: Ready) { info!(user = %ready.user.name, "discord bot connected"); + + // Register /model slash command to all guilds the bot is in + for guild in &ready.guilds { + let guild_id = guild.id; + if let Err(e) = guild_id + .set_commands( + &ctx.http, + vec![ + CreateCommand::new("models") + .description("Select the AI model for this session"), + CreateCommand::new("agents") + .description("Select the agent mode for this session"), + CreateCommand::new("cancel") + .description("Cancel the current operation"), + ], + ) + .await + { + tracing::warn!(%guild_id, error = %e, "failed to register slash commands"); + } else { + info!(%guild_id, "registered slash commands"); + } + } + } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + match interaction { + Interaction::Command(cmd) if cmd.data.name == "models" => { + self.handle_config_command(&ctx, &cmd, "model", "model").await; + } + Interaction::Command(cmd) if cmd.data.name == "agents" => { + self.handle_config_command(&ctx, &cmd, "agent", "agent").await; + } + Interaction::Command(cmd) if cmd.data.name == "cancel" => { + self.handle_cancel_command(&ctx, &cmd).await; + } + Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => { + self.handle_config_select(&ctx, &comp).await; + } + _ => {} + } + } +} + + +// --- Slash command & interaction handlers --- + +impl Handler { + /// Build a Discord select menu from ACP configOptions with the given category. + fn build_config_select(options: &[ConfigOption], category: &str) -> Option { + let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; + let menu_options: Vec = opt + .options + .iter() + .map(|o| { + let mut item = CreateSelectMenuOption::new(&o.name, &o.value); + if let Some(desc) = &o.description { + item = item.description(desc); + } + if o.value == opt.current_value { + item = item.default_selection(true); + } + item + }) + .collect(); + + if menu_options.is_empty() { + return None; + } + + Some( + CreateSelectMenu::new( + format!("acp_config_{}", opt.id), + CreateSelectMenuKind::String { options: menu_options }, + ) + .placeholder(format!("Current: {}", opt.options.iter() + .find(|o| o.value == opt.current_value) + .map(|o| o.name.as_str()) + .unwrap_or(&opt.current_value))) + ) + } + + async fn handle_config_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + category: &str, + label: &str, + ) { + let thread_key = format!("discord:{}", cmd.channel_id.get()); + let config_options = self.router.pool().get_config_options(&thread_key).await; + let select = Self::build_config_select(&config_options, category); + + let response = match select { + Some(menu) => CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("🔧 Select a {label}:")) + .components(vec![CreateActionRow::SelectMenu(menu)]) + .ephemeral(true), + ), + None => CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ No {label} options available. Start a conversation first by @mentioning the bot.")) + .ephemeral(true), + ), + }; + + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, category, "failed to respond to slash command"); + } + } + + async fn handle_cancel_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + let thread_key = format!("discord:{}", cmd.channel_id.get()); + let result = self.router.pool().cancel_session(&thread_key).await; + + let msg = match result { + Ok(()) => "🛑 Cancel signal sent.".to_string(), + Err(e) => format!("⚠️ {e}"), + }; + + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to respond to /cancel command"); + } + } + + async fn handle_config_select( + &self, + ctx: &Context, + comp: &serenity::model::application::ComponentInteraction, + ) { + let config_id = comp + .data + .custom_id + .strip_prefix("acp_config_") + .unwrap_or("") + .to_string(); + + if config_id.is_empty() { + return; + } + + let selected_value = match &comp.data.kind { + ComponentInteractionDataKind::StringSelect { values } => { + match values.first() { + Some(v) => v.clone(), + None => return, + } + } + _ => return, + }; + + let thread_key = format!("discord:{}", comp.channel_id.get()); + + let result = self + .router + .pool() + .set_config_option(&thread_key, &config_id, &selected_value) + .await; + + let response_msg = match result { + Ok(updated_options) => { + let display_name = updated_options + .iter() + .find(|o| o.id == config_id) + .and_then(|o| o.options.iter().find(|v| v.value == selected_value)) + .map(|v| v.name.as_str()) + .unwrap_or(&selected_value); + format!("✅ Switched to **{}**", display_name) + } + Err(e) => { + tracing::error!(error = %e, "failed to set config option"); + format!("❌ Failed to switch: {}", e) + } + }; + + let response = CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new().content(response_msg).components(vec![]), + ); + + if let Err(e) = comp.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to respond to config select"); + } } }