Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 80 additions & 3 deletions src/apps/desktop/src/api/acp_client_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
use crate::api::app_state::AppState;
use crate::api::session_storage_path::desktop_effective_session_storage_path;
use bitfun_acp::client::{
AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent,
AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest,
SubmitAcpPermissionResponseRequest,
AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe,
AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse,
SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
Expand Down Expand Up @@ -377,6 +377,50 @@ pub async fn start_acp_dialog_turn(
bitfun_core::util::errors::BitFunError::service(e.to_string())
})?;
}
AcpClientStreamEvent::AvailableCommandsUpdated(commands) => {
app_handle
.emit(
"agentic://acp-available-commands-updated",
serde_json::json!({
"sessionId": request.session_id,
"clientId": request.client_id,
"commands": commands,
}),
)
.map_err(|e| {
bitfun_core::util::errors::BitFunError::service(e.to_string())
})?;
}
AcpClientStreamEvent::PlanUpdated(entries) => {
app_handle
.emit(
"agentic://acp-plan-updated",
serde_json::json!({
"sessionId": request.session_id,
"turnId": request.turn_id,
"clientId": request.client_id,
"entries": entries,
}),
)
.map_err(|e| {
bitfun_core::util::errors::BitFunError::service(e.to_string())
})?;
}
AcpClientStreamEvent::ConfigOptionsUpdated(_) => {
// The stored options were refreshed backend-side; tell
// the frontend to refetch the combined session options.
app_handle
.emit(
"agentic://acp-session-options-changed",
serde_json::json!({
"sessionId": request.session_id,
"clientId": request.client_id,
}),
)
.map_err(|e| {
bitfun_core::util::errors::BitFunError::service(e.to_string())
})?;
}
AcpClientStreamEvent::Completed => {
app_handle
.emit(
Expand Down Expand Up @@ -482,6 +526,39 @@ pub async fn get_acp_session_options(
.map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn get_acp_session_commands(
state: State<'_, AppState>,
request: GetAcpSessionOptionsRequest,
) -> Result<Vec<AcpAvailableCommand>, String> {
let service = state
.acp_client_service
.as_ref()
.ok_or_else(|| "ACP client service not initialized".to_string())?;
let session_storage_path = match request.workspace_path.as_deref() {
Some(workspace_path) => Some(
desktop_effective_session_storage_path(
&state,
workspace_path,
request.remote_connection_id.as_deref(),
request.remote_ssh_host.as_deref(),
)
.await,
),
None => None,
};
service
.get_session_commands(
&request.client_id,
request.workspace_path,
request.remote_connection_id,
session_storage_path,
request.session_id,
)
.await
.map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn set_acp_session_model(
state: State<'_, AppState>,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ pub async fn run() {
start_acp_dialog_turn,
cancel_acp_dialog_turn,
get_acp_session_options,
get_acp_session_commands,
set_acp_session_model,
lsp_initialize,
lsp_start_server_for_file,
Expand Down
72 changes: 71 additions & 1 deletion src/crates/acp/src/client/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ use super::requirements::{
probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command,
};
use super::session_options::{
model_config_id, session_options_from_state, AcpSessionContextUsage, AcpSessionOptions,
model_config_id, session_options_from_state, AcpAvailableCommand, AcpSessionContextUsage,
AcpSessionOptions,
};
use super::session_persistence::AcpSessionPersistence;
pub use super::session_persistence::CreateAcpFlowSessionRecordResponse;
Expand Down Expand Up @@ -132,6 +133,8 @@ struct AcpRemoteSession {
models: Option<SessionModelState>,
config_options: Vec<SessionConfigOption>,
context_usage: Option<AcpSessionContextUsage>,
/// Latest slash commands advertised by the agent (AvailableCommandsUpdate).
available_commands: Vec<AcpAvailableCommand>,
discard_pending_updates_before_next_prompt: bool,
}

Expand Down Expand Up @@ -160,6 +163,7 @@ impl AcpRemoteSession {
models: None,
config_options: Vec::new(),
context_usage: None,
available_commands: Vec::new(),
discard_pending_updates_before_next_prompt: false,
}
}
Expand Down Expand Up @@ -869,6 +873,39 @@ impl AcpClientService {
))
}

/// Slash commands the agent has advertised for this session
/// (via `AvailableCommandsUpdate`). Returns an empty list until the agent
/// sends them (typically during/after the first prompt).
pub async fn get_session_commands(
self: &Arc<Self>,
client_id: &str,
workspace_path: Option<String>,
remote_connection_id: Option<String>,
session_storage_path: Option<PathBuf>,
bitfun_session_id: String,
) -> BitFunResult<Vec<AcpAvailableCommand>> {
let resolved = self
.resolve_or_create_client_session(
client_id,
workspace_path,
remote_connection_id.as_deref(),
&bitfun_session_id,
)
.await?;

let mut session = resolved.session.lock().await;
self.ensure_remote_session(
&resolved.client,
&resolved.session_key,
&resolved.cwd,
&bitfun_session_id,
session_storage_path.as_deref(),
&mut session,
)
.await?;
Ok(session.available_commands.clone())
}

pub async fn set_session_model(
self: &Arc<Self>,
request: SetAcpSessionModelRequest,
Expand Down Expand Up @@ -1081,6 +1118,8 @@ impl AcpClientService {
)
.await?;
update_session_context_usage(&mut session, &events);
update_session_available_commands(&mut session, &events);
update_session_config_options(&mut session, &events);
for event in events {
for event in round_tracker.apply(event) {
on_event(event)?;
Expand Down Expand Up @@ -2208,6 +2247,37 @@ fn update_session_context_usage(session: &mut AcpRemoteSession, events: &[AcpCli
session.context_usage = Some(usage);
}

fn update_session_available_commands(
session: &mut AcpRemoteSession,
events: &[AcpClientStreamEvent],
) {
// The agent re-sends the full command list on each update, so the latest
// wins (replace rather than merge).
let Some(commands) = events.iter().rev().find_map(|event| match event {
AcpClientStreamEvent::AvailableCommandsUpdated(commands) => Some(commands.clone()),
_ => None,
}) else {
return;
};

session.available_commands = commands;
}

fn update_session_config_options(
session: &mut AcpRemoteSession,
events: &[AcpClientStreamEvent],
) {
// The agent sends the full set each time, so the latest update wins.
let Some(options) = events.iter().rev().find_map(|event| match event {
AcpClientStreamEvent::ConfigOptionsUpdated(options) => Some(options.clone()),
_ => None,
}) else {
return;
};

session.config_options = options;
}

fn protocol_error(error: impl std::fmt::Display) -> BitFunError {
BitFunError::service(format!("ACP protocol error: {}", error))
}
Expand Down
4 changes: 3 additions & 1 deletion src/crates/acp/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@ pub use manager::{
AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse,
SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest,
};
pub use session_options::{AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions};
pub use session_options::{
AcpAvailableCommand, AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions,
};
pub use stream::AcpClientStreamEvent;
92 changes: 92 additions & 0 deletions src/crates/acp/src/client/session_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,77 @@ impl From<agent_client_protocol::schema::UsageUpdate> for AcpSessionContextUsage
}
}

/// A slash command advertised by an ACP agent via `AvailableCommandsUpdate`.
///
/// Surfaced to the frontend so it can render a `/` command menu. Invocation is
/// plain text: the client sends `session/prompt` with `/<name> <args>` — there
/// is no dedicated command RPC in ACP.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpAvailableCommand {
/// Command name without the leading slash (e.g. `create_plan`).
pub name: String,
/// Human-readable description.
pub description: String,
/// Hint for the unstructured input typed after the command name, if the
/// command takes input. `None` means the command takes no arguments.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_hint: Option<String>,
}

impl From<agent_client_protocol::schema::AvailableCommand> for AcpAvailableCommand {
fn from(command: agent_client_protocol::schema::AvailableCommand) -> Self {
use agent_client_protocol::schema::AvailableCommandInput;
let input_hint = command.input.and_then(|input| match input {
AvailableCommandInput::Unstructured(unstructured) => Some(unstructured.hint),
// `AvailableCommandInput` is #[non_exhaustive]; unknown future input
// kinds carry no hint we can render.
_ => None,
});
Self {
name: command.name,
description: command.description,
input_hint,
}
}
}

/// One entry of an agent's execution plan (ACP `Plan` / agent-plan), surfaced to
/// the frontend so it can render a live task checklist during a turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpPlanEntry {
/// Human-readable description of the task.
pub content: String,
/// `"high"` | `"medium"` | `"low"`.
pub priority: String,
/// `"pending"` | `"in_progress"` | `"completed"`.
pub status: String,
}

impl From<agent_client_protocol::schema::PlanEntry> for AcpPlanEntry {
fn from(entry: agent_client_protocol::schema::PlanEntry) -> Self {
use agent_client_protocol::schema::{PlanEntryPriority, PlanEntryStatus};
let priority = match entry.priority {
PlanEntryPriority::High => "high",
PlanEntryPriority::Medium => "medium",
PlanEntryPriority::Low => "low",
_ => "medium",
};
let status = match entry.status {
PlanEntryStatus::Pending => "pending",
PlanEntryStatus::InProgress => "in_progress",
PlanEntryStatus::Completed => "completed",
_ => "pending",
};
Self {
content: entry.content,
priority: priority.to_string(),
status: status.to_string(),
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AcpSessionOptions {
Expand Down Expand Up @@ -198,4 +269,25 @@ mod tests {
Some("USD")
);
}

#[test]
fn converts_available_command_with_and_without_input() {
use agent_client_protocol::schema::{
AvailableCommand, AvailableCommandInput, UnstructuredCommandInput,
};

let with_input = AvailableCommand::new("create_plan", "Draft an execution plan")
.input(AvailableCommandInput::Unstructured(
UnstructuredCommandInput::new("what to plan"),
));
let converted = AcpAvailableCommand::from(with_input);
assert_eq!(converted.name, "create_plan");
assert_eq!(converted.description, "Draft an execution plan");
assert_eq!(converted.input_hint.as_deref(), Some("what to plan"));

let no_input = AvailableCommand::new("compact", "Compact the context");
let converted = AcpAvailableCommand::from(no_input);
assert_eq!(converted.name, "compact");
assert!(converted.input_hint.is_none());
}
}
Loading
Loading