From 3f52c1084caaaa41dc6303823c660e8aadd26b8e Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sun, 19 Apr 2026 18:58:57 +0000 Subject: [PATCH 1/6] feat: /model slash command with ACP configOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements dynamic model selection via Discord slash command that bridges to ACP session/set_config_option. Flow: 1. Bot registers /model slash command on ready 2. User invokes /model → OpenAB reads cached configOptions (category: model) 3. Discord Select Menu rendered with available models 4. User selects → OpenAB sends session/set_config_option to ACP agent 5. Agent confirms → Discord message updated Changes: - protocol.rs: Add ConfigOption/ConfigOptionValue types + parse_config_options() - connection.rs: Cache configOptions on session_new, add set_config_option() - pool.rs: Expose get_config_options() and set_config_option() on SessionPool - discord.rs: Register /model command in ready(), handle interaction_create() for both slash commands and component select menus Closes #477 --- docs/kiro.md | 25 +++++++ src/acp/connection.rs | 40 +++++++++- src/acp/pool.rs | 32 ++++++++ src/acp/protocol.rs | 38 ++++++++++ src/adapter.rs | 8 ++ src/discord.rs | 167 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 307 insertions(+), 3 deletions(-) diff --git a/docs/kiro.md b/docs/kiro.md index dbf5697..e2deaad 100644 --- a/docs/kiro.md +++ b/docs/kiro.md @@ -50,3 +50,28 @@ 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 + +### `/models` — Switch AI Model + +When using Kiro CLI as the backend, the `/models` slash command lets users dynamically switch models via a Discord select menu. + +**How it works:** +1. Kiro CLI returns available models via ACP `configOptions` (category: `"model"`) on session creation +2. User types `/models` in a thread with an active session +3. A select menu appears with available models (e.g. Sonnet 4, Opus 4, Haiku 4) +4. User picks a model → OpenAB sends `session/set_config_option` to Kiro +5. Model switches immediately for that session + +**Note:** The `/models` command only works in threads where a conversation is already active. If no session exists, it will prompt the user to start one first. + +> ⚠️ This feature has only been tested with Kiro CLI. Other ACP backends (Claude Code, Codex, Gemini) may or may not return `configOptions` with model choices — behavior will vary by agent. + +### Future Commands + +| Command | Purpose | Status | +|---------|---------|--------| +| `/models` | Switch AI model | ✅ Implemented | +| `/agents` | Switch agent backend | 🔜 Planned | +| `/cancel` | Cancel current generation | 🔜 Planned | diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 879a398..ba63377 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, @@ -352,9 +354,42 @@ 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?; + + if let Some(result) = resp.result.as_ref() { + self.config_options = parse_config_options(result); + } + info!(config_id, value, "config option set"); + 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). @@ -422,6 +457,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..f065312 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; @@ -229,6 +230,37 @@ 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 + } + 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..3c211f1 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -54,6 +54,39 @@ 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. +pub fn parse_config_options(result: &Value) -> Vec { + result + .get("configOptions") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default() +} + // --- ACP notification classification --- #[derive(Debug)] @@ -62,6 +95,7 @@ pub enum AcpEvent { Thinking, ToolStart { id: String, title: String }, ToolDone { id: String, title: String, status: String }, + ConfigUpdate { options: Vec }, Status, } @@ -105,6 +139,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..5595815 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,169 @@ 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"), + ], + ) + .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_model_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_model_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + // thread_key must match the format used in adapter.rs handle_message: + // "{platform}:{channel_or_thread_id}" + 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, "model"); + + let response = match select { + Some(menu) => CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("🔧 Select a model:") + .components(vec![CreateActionRow::SelectMenu(menu)]) + .ephemeral(true), + ), + None => CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ No model 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, "failed to respond to /model 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"); + } } } From 115634f8edd94958a12a124e00a28aea2c6512ac Mon Sep 17 00:00:00 2001 From: thepagent Date: Sun, 19 Apr 2026 18:26:10 -0400 Subject: [PATCH 2/6] feat: add /agents and /cancel slash commands - /agents: select agent mode (category: agent) via generic handle_config_command - /cancel: send session/cancel notification to abort in-flight operations - Refactor handle_model_command into generic handle_config_command(category, label) - Add pool.cancel_session() using send_raw for JSON-RPC notification - Make AcpConnection::send_raw pub for pool cancel access --- Cargo.lock | 2 +- src/acp/connection.rs | 2 +- src/acp/pool.rs | 19 +++++++++++++++++ src/discord.rs | 49 ++++++++++++++++++++++++++++++++++--------- 4 files changed, 60 insertions(+), 12 deletions(-) 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/src/acp/connection.rs b/src/acp/connection.rs index ba63377..32ce487 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -280,7 +280,7 @@ impl AcpConnection { self.next_id.fetch_add(1, Ordering::Relaxed) } - async fn send_raw(&self, data: &str) -> Result<()> { + pub 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?; diff --git a/src/acp/pool.rs b/src/acp/pool.rs index f065312..7708311 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -261,6 +261,25 @@ impl SessionPool { conn.set_config_option(config_id, value).await } + /// Cancel the current in-flight operation for a session. + pub async fn cancel_session(&self, thread_id: &str) -> Result<()> { + let conn = { + let state = self.state.read().await; + state.active.get(thread_id).cloned() + .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? + }; + let conn = conn.lock().await; + let session_id = conn.acp_session_id.as_ref() + .ok_or_else(|| anyhow!("no active session"))?; + 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"); + conn.send_raw(&data).await + } + pub async fn cleanup_idle(&self, ttl_secs: u64) { let cutoff = Instant::now() - std::time::Duration::from_secs(ttl_secs); diff --git a/src/discord.rs b/src/discord.rs index 5595815..ac94759 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -537,6 +537,10 @@ impl EventHandler for Handler { 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 @@ -551,7 +555,13 @@ impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { match interaction { Interaction::Command(cmd) if cmd.data.name == "models" => { - self.handle_model_command(&ctx, &cmd).await; + 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; @@ -599,35 +609,54 @@ impl Handler { ) } - async fn handle_model_command( + async fn handle_config_command( &self, ctx: &Context, cmd: &serenity::model::application::CommandInteraction, + category: &str, + label: &str, ) { - // thread_key must match the format used in adapter.rs handle_message: - // "{platform}:{channel_or_thread_id}" 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, "model"); + let select = Self::build_config_select(&config_options, category); let response = match select { Some(menu) => CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content("🔧 Select a model:") + .content(format!("🔧 Select a {label}:")) .components(vec![CreateActionRow::SelectMenu(menu)]) .ephemeral(true), ), None => CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content("⚠️ No model options available. Start a conversation first by @mentioning the bot.") + .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, "failed to respond to /model command"); + 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"); } } From b032173a194772d1923e69d1cd666e5dd49ee11e Mon Sep 17 00:00:00 2001 From: thepagent Date: Sun, 19 Apr 2026 18:27:19 -0400 Subject: [PATCH 3/6] =?UTF-8?q?docs(kiro):=20update=20slash=20commands=20s?= =?UTF-8?q?ection=20=E2=80=94=20all=203=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/kiro.md | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/kiro.md b/docs/kiro.md index e2deaad..8b7b72e 100644 --- a/docs/kiro.md +++ b/docs/kiro.md @@ -53,25 +53,24 @@ kubectl rollout restart deployment/openab-kiro ## Slash Commands +| Command | Purpose | Status | +|---------|---------|--------| +| `/models` | Switch AI model | ✅ Implemented | +| `/agents` | Switch agent mode | ✅ Implemented | +| `/cancel` | Cancel current generation | ✅ Implemented | + ### `/models` — Switch AI Model -When using Kiro CLI as the backend, the `/models` slash command lets users dynamically switch models via a Discord select menu. +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). -**How it works:** -1. Kiro CLI returns available models via ACP `configOptions` (category: `"model"`) on session creation -2. User types `/models` in a thread with an active session -3. A select menu appears with available models (e.g. Sonnet 4, Opus 4, Haiku 4) -4. User picks a model → OpenAB sends `session/set_config_option` to Kiro -5. Model switches immediately for that session +### `/agents` — Switch Agent Mode -**Note:** The `/models` command only works in threads where a conversation is already active. If no session exists, it will prompt the user to start one first. +Same mechanism as `/models` but for the `agent` category. Kiro CLI exposes modes like `kiro_default` and `kiro_planner` via `configOptions`. -> ⚠️ This feature has only been tested with Kiro CLI. Other ACP backends (Claude Code, Codex, Gemini) may or may not return `configOptions` with model choices — behavior will vary by agent. +### `/cancel` — Cancel Current Operation -### Future Commands +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. -| Command | Purpose | Status | -|---------|---------|--------| -| `/models` | Switch AI model | ✅ Implemented | -| `/agents` | Switch agent backend | 🔜 Planned | -| `/cancel` | Cancel current generation | 🔜 Planned | +**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. From 9a02ec84de983dbd997dc070d253df27894c2071 Mon Sep 17 00:00:00 2001 From: thepagent Date: Sun, 19 Apr 2026 18:29:21 -0400 Subject: [PATCH 4/6] docs(kiro): mention built-in kiro-cli commands via @mention --- docs/kiro.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/kiro.md b/docs/kiro.md index 8b7b72e..22e1d96 100644 --- a/docs/kiro.md +++ b/docs/kiro.md @@ -74,3 +74,15 @@ Sends a `session/cancel` JSON-RPC notification to abort in-flight LLM requests a **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. From e5d8480320e02c2b8c73f6622faa8043f6394e2c Mon Sep 17 00:00:00 2001 From: thepagent Date: Sun, 19 Apr 2026 18:33:56 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20lock-free=20/cancel=20=E2=80=94=20by?= =?UTF-8?q?pass=20connection=20mutex=20during=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store cancel handles (stdin + session_id) separately in PoolState. cancel_session() writes directly to stdin without locking the connection, so it works even while the streaming loop holds the connection lock. --- src/acp/connection.rs | 5 +++++ src/acp/pool.rs | 24 ++++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 32ce487..3692819 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -438,6 +438,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() } diff --git a/src/acp/pool.rs b/src/acp/pool.rs index 7708311..e1d27bf 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -13,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, @@ -63,6 +66,7 @@ impl SessionPool { Self { state: RwLock::new(PoolState { active: HashMap::new(), + cancel_handles: HashMap::new(), suspended: HashMap::new(), creating: HashMap::new(), }), @@ -164,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; @@ -207,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(()) } @@ -262,22 +271,25 @@ impl SessionPool { } /// 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 conn = { + let (stdin, session_id) = { let state = self.state.read().await; - state.active.get(thread_id).cloned() + state.cancel_handles.get(thread_id).cloned() .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? }; - let conn = conn.lock().await; - let session_id = conn.acp_session_id.as_ref() - .ok_or_else(|| anyhow!("no active session"))?; 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"); - conn.send_raw(&data).await + 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) { From 28127e8f4e4692a4176bac96c57ea4904391ee5c Mon Sep 17 00:00:00 2001 From: thepagent Date: Sun, 19 Apr 2026 18:36:15 -0400 Subject: [PATCH 6/6] feat: kiro-cli fallback for configOptions + set_config_option - parse_config_options: fall back to kiro models/modes format when configOptions is absent - set_config_option: fall back to sending / as prompt when session/set_config_option is not supported - Tighten send_raw to pub(crate) --- src/acp/connection.rs | 34 +++++++++++++++++--- src/acp/protocol.rs | 72 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 3692819..9cdd60b 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -280,7 +280,7 @@ impl AcpConnection { self.next_id.fetch_add(1, Ordering::Relaxed) } - pub 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?; @@ -381,12 +381,36 @@ impl AcpConnection { "value": value, })), ) - .await?; + .await; - if let Some(result) = resp.result.as_ref() { - self.config_options = parse_config_options(result); + 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(); + } + } + } } - info!(config_id, value, "config option set"); + Ok(self.config_options.clone()) } diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 3c211f1..5860685 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -80,11 +80,79 @@ pub struct ConfigOption { } /// 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 { - result + if let Some(opts) = result .get("configOptions") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) - .unwrap_or_default() + { + 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 ---