diff --git a/src/crates/interfaces/acp/src/runtime.rs b/src/crates/interfaces/acp/src/runtime.rs index 92b890a70..471fe8ae5 100644 --- a/src/crates/interfaces/acp/src/runtime.rs +++ b/src/crates/interfaces/acp/src/runtime.rs @@ -19,6 +19,7 @@ use dashmap::DashMap; use crate::server::{AcpRuntime, AcpServer}; +mod commands; mod content; mod events; mod mcp; diff --git a/src/crates/interfaces/acp/src/runtime/commands.rs b/src/crates/interfaces/acp/src/runtime/commands.rs new file mode 100644 index 000000000..27946ceac --- /dev/null +++ b/src/crates/interfaces/acp/src/runtime/commands.rs @@ -0,0 +1,137 @@ +use agent_client_protocol::schema::{ + AvailableCommand, AvailableCommandsUpdate, ContentChunk, PromptResponse, SessionUpdate, + StopReason, +}; +use agent_client_protocol::{Client, ConnectionTo, Result}; + +use super::events::send_update; +use super::AcpSessionState; + +/// Returns the list of built-in slash commands advertised to ACP clients. +pub(super) fn builtin_commands() -> Vec { + vec![ + AvailableCommand::new("help", "Show available commands"), + AvailableCommand::new("clear", "Clear the conversation context"), + AvailableCommand::new("compact", "Compact and summarize the conversation context"), + AvailableCommand::new("status", "Show current session status"), + ] +} + +/// Builds the [`SessionUpdate`] that advertises built-in commands to the client. +pub(super) fn builtin_commands_update() -> SessionUpdate { + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(builtin_commands())) +} + +/// Sends the built-in commands list to the client. Errors are logged and +/// discarded so that command advertisement never blocks session creation. +pub(super) fn advertise_builtin_commands(connection: &ConnectionTo, session_id: &str) { + if let Err(error) = send_update(connection, session_id, builtin_commands_update()) { + log::warn!( + "Failed to advertise built-in commands to ACP client for session {}: {}", + session_id, + error + ); + } +} + +/// Attempts to handle a built-in slash command locally without forwarding the +/// prompt to the agent runtime. +/// +/// Returns `Ok(Some(response))` when `user_message` is a recognized built-in +/// command that has been handled. Returns `Ok(None)` when the message is not a +/// built-in command and should be submitted to the agent runtime normally. +pub(super) fn try_handle_builtin_command( + connection: &ConnectionTo, + session: &AcpSessionState, + user_message: &str, +) -> Result> { + let trimmed = user_message.trim(); + if !trimmed.starts_with('/') { + return Ok(None); + } + + let command_name = trimmed + .split_whitespace() + .next() + .unwrap_or("") + .trim_start_matches('/') + .to_lowercase(); + + let response_text = match command_name.as_str() { + "help" => format_help_text(), + "status" => format_status_text(session), + "clear" => "Context clearing is not yet available via slash command. \ + Please use the client's clear button or the session/clear RPC method." + .to_string(), + "compact" => "Context compaction is not yet available via slash command.".to_string(), + _ => return Ok(None), + }; + + send_update( + connection, + &session.acp_session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new(response_text.into())), + )?; + + Ok(Some(PromptResponse::new(StopReason::EndTurn))) +} + +fn format_help_text() -> String { + let commands = builtin_commands(); + let mut text = String::from("Available commands:\n\n"); + for cmd in &commands { + text.push_str(&format!("/{} - {}\n", cmd.name, cmd.description)); + } + text.push_str("\nType any command to execute it."); + text +} + +fn format_status_text(session: &AcpSessionState) -> String { + format!( + "Session Status:\n Session ID: {}\n Mode: {}\n Model: {}\n Working Directory: {}", + session.acp_session_id, session.mode_id, session.model_id, session.cwd, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_commands_have_unique_names() { + use std::collections::HashSet; + let commands = builtin_commands(); + let names: HashSet<&str> = commands.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names.len(), commands.len(), "command names must be unique"); + } + + #[test] + fn help_text_lists_all_commands() { + let text = format_help_text(); + for cmd in &builtin_commands() { + assert!( + text.contains(&format!("/{}", cmd.name)), + "help text must mention /{}", + cmd.name + ); + } + } + + #[test] + fn status_text_includes_session_fields() { + let session = AcpSessionState { + acp_session_id: "test-session".to_string(), + bitfun_session_id: "test-bitfun".to_string(), + cwd: "/tmp/work".to_string(), + mode_id: "code".to_string(), + model_id: "test-model".to_string(), + mcp_server_ids: Vec::new(), + lifecycle: std::sync::Arc::new(tokio::sync::Mutex::new(())), + }; + let text = format_status_text(&session); + assert!(text.contains("test-session")); + assert!(text.contains("code")); + assert!(text.contains("test-model")); + assert!(text.contains("/tmp/work")); + } +} diff --git a/src/crates/interfaces/acp/src/runtime/prompt.rs b/src/crates/interfaces/acp/src/runtime/prompt.rs index 3492a2d0b..69b7d7f3d 100644 --- a/src/crates/interfaces/acp/src/runtime/prompt.rs +++ b/src/crates/interfaces/acp/src/runtime/prompt.rs @@ -39,6 +39,14 @@ impl BitfunAcpRuntime { return Err(Error::invalid_params().data("empty prompt")); } + if let Some(response) = super::commands::try_handle_builtin_command( + &connection, + &acp_session, + &parsed_prompt.user_message, + )? { + return Ok(response); + } + let mut event_rx = self .agent_runtime .subscribe_session_events(&acp_session.bitfun_session_id) diff --git a/src/crates/interfaces/acp/src/runtime/session.rs b/src/crates/interfaces/acp/src/runtime/session.rs index 9488411c6..1d14dfed4 100644 --- a/src/crates/interfaces/acp/src/runtime/session.rs +++ b/src/crates/interfaces/acp/src/runtime/session.rs @@ -144,6 +144,9 @@ impl BitfunAcpRuntime { } return Err(error); } + if let Some(conn) = self.connections.get(&acp_session.acp_session_id) { + super::commands::advertise_builtin_commands(conn.value(), &acp_session.acp_session_id); + } Ok(response) } @@ -254,6 +257,9 @@ impl BitfunAcpRuntime { } return Err(error); } + if let Some(conn) = self.connections.get(&acp_session.acp_session_id) { + super::commands::advertise_builtin_commands(conn.value(), &acp_session.acp_session_id); + } Ok(response) }