diff --git a/crates/ai/src/acp/bridge.rs b/crates/ai/src/acp/bridge.rs index 81f32b907..3b2d97899 100644 --- a/crates/ai/src/acp/bridge.rs +++ b/crates/ai/src/acp/bridge.rs @@ -6,8 +6,8 @@ use super::{ config::AgentRegistry, process::{stop_child_tree, terminate_process_group}, types::{ - AcpAgentCapabilities, AcpAgentStatus, AcpEvent, AcpSessionInfo, AcpSessionList, AgentConfig, - SessionConfigOption, + AcpAgentCapabilities, AcpAgentStatus, AcpErrorKind, AcpEvent, AcpSessionInfo, AcpSessionList, + AgentConfig, SessionConfigOption, }, }; use crate::runtime::AthasAppHandle as AppHandle; @@ -24,6 +24,24 @@ use tokio::{ task::LocalSet, }; +fn classify_acp_error(error: &str) -> Option { + let lower = error.to_lowercase(); + + if lower.contains("authentication required") { + return Some(AcpErrorKind::AuthenticationRequired); + } + + let requires_provider_setup = lower.contains("no api key found") + || lower.contains("missing api key") + || (lower.contains("api key") && lower.contains("required")) + || lower.contains("environment variable") + || lower.contains("--setup") + || lower.contains("not logged in") + || lower.contains("login required"); + + requires_provider_setup.then_some(AcpErrorKind::ProviderSetupRequired) +} + /// Worker state running on the LocalSet thread pub(super) struct AcpWorker { connection: Option>, @@ -68,6 +86,7 @@ impl AcpWorker { AcpEvent::Error { session_id: session_id.clone(), error: format!("ACP agent process exited: {}", status), + error_kind: None, }, ); let _ = app_handle.emit( @@ -188,11 +207,13 @@ impl AcpWorker { .await { log::error!("Failed to run ACP prompt: {}", err); + let error = format!("Failed to run prompt: {}", err); let _ = app_handle.emit( "acp-event", AcpEvent::Error { session_id: Some(session_id.to_string()), - error: format!("Failed to run prompt: {}", err), + error_kind: classify_acp_error(&error), + error, }, ); } @@ -641,3 +662,32 @@ impl AcpAgentBridge { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_provider_setup_errors() { + let error = + "Failed to run prompt: No API key found. Set Z_AI_API_KEY or run glm-acp-agent --setup"; + + assert_eq!( + classify_acp_error(error), + Some(AcpErrorKind::ProviderSetupRequired) + ); + } + + #[test] + fn classifies_authentication_required_errors() { + assert_eq!( + classify_acp_error("Authentication required before sending prompt"), + Some(AcpErrorKind::AuthenticationRequired) + ); + } + + #[test] + fn leaves_plain_runtime_errors_unclassified() { + assert_eq!(classify_acp_error("ACP agent process exited: 1"), None); + } +} diff --git a/crates/ai/src/acp/bridge_init.rs b/crates/ai/src/acp/bridge_init.rs index 921ce4f43..ff7397133 100644 --- a/crates/ai/src/acp/bridge_init.rs +++ b/crates/ai/src/acp/bridge_init.rs @@ -40,7 +40,7 @@ pub(super) async fn initialize_worker( requested_session_id: Option, map_config_options: impl Fn(Vec) -> Vec, ) -> Result { - let (mut child, uses_npx_codex_adapter) = + let (mut child, uses_lazy_package_runner) = spawn_agent_process(config, workspace_path.as_deref())?; let process_group_id = child.id(); let stdin = child @@ -77,7 +77,7 @@ pub(super) async fn initialize_worker( let init_response = initialize_connection( connection.clone(), - uses_npx_codex_adapter, + uses_lazy_package_runner, &mut child, &io_handle, ) @@ -104,6 +104,8 @@ pub(super) async fn initialize_worker( SessionBootstrapContext { auth_methods, supports_session_resume, + default_mode: config.default_mode.clone(), + default_model: config.default_model.clone(), map_config_options, child: &mut child, io_handle: &io_handle, @@ -143,6 +145,8 @@ where { auth_methods: Vec, supports_session_resume: bool, + default_mode: Option, + default_model: Option, map_config_options: F, child: &'a mut Child, io_handle: &'a tokio::task::JoinHandle<()>, @@ -166,17 +170,18 @@ fn spawn_agent_process( workspace_path: Option<&str>, ) -> Result<(Child, bool)> { let binary = config.binary_path.as_deref().unwrap_or(&config.binary_name); + let args = launch_args(config); log::info!( "Starting agent '{}' (binary: {}, resolved: {}, args: {:?})", config.name, config.binary_name, binary, - config.args + args ); let mut cmd = Command::new(binary); configure_background_agent_command(&mut cmd); - cmd.args(&config.args) + cmd.args(&args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -187,11 +192,7 @@ fn spawn_agent_process( cmd.env("PATH", format!("{current}:{shell_path}")); } - let uses_npx_codex_adapter = binary.ends_with("npx") - && config - .args - .iter() - .any(|arg| arg == "@zed-industries/codex-acp"); + let uses_lazy_package_runner = binary.ends_with("npx") && args.iter().any(|arg| arg == "-y"); for (key, value) in &config.env_vars { cmd.env(key, value); @@ -201,7 +202,32 @@ fn spawn_agent_process( cmd.current_dir(path); } - Ok((cmd.spawn()?, uses_npx_codex_adapter)) + Ok((cmd.spawn()?, uses_lazy_package_runner)) +} + +fn launch_args(config: &AgentConfig) -> Vec { + if config.id != "qwen-code" { + return config.args.clone(); + } + + let mut args = config + .args + .iter() + .filter(|arg| arg.as_str() != "--experimental-skills") + .map(|arg| { + if arg == "--acp" || arg == "acp" { + "--experimental-acp".to_string() + } else { + arg.clone() + } + }) + .collect::>(); + + if !args.iter().any(|arg| arg == "--experimental-acp") { + args.push("--experimental-acp".to_string()); + } + + args } fn spawn_stderr_logger(child: &mut Child, agent_name: String) { @@ -218,7 +244,7 @@ fn spawn_stderr_logger(child: &mut Child, agent_name: String) { async fn initialize_connection( connection: Arc, - uses_npx_codex_adapter: bool, + uses_lazy_package_runner: bool, child: &mut Child, io_handle: &tokio::task::JoinHandle<()>, ) -> Result { @@ -248,7 +274,7 @@ async fn initialize_connection( .client_capabilities(client_capabilities) .client_info(acp::Implementation::new("athas", env!("CARGO_PKG_VERSION")).title("Athas")); - let initialize_timeout_secs = if uses_npx_codex_adapter { 120 } else { 30 }; + let initialize_timeout_secs = if uses_lazy_package_runner { 120 } else { 30 }; log::info!( "Sending ACP initialize request (timeout: {}s)...", initialize_timeout_secs @@ -340,6 +366,14 @@ async fn bootstrap_session( match load_result { Ok(Ok(load_response)) => { + apply_session_defaults( + connection.clone(), + acp::SessionId::new(existing_session_id.clone()), + ctx.default_mode.as_deref(), + ctx.default_model.as_deref(), + load_response.config_options.as_ref(), + ) + .await; log::info!("ACP session loaded: {}", existing_session_id); client.set_session_id(existing_session_id.clone()).await; return Ok(SessionBootstrap { @@ -375,6 +409,14 @@ async fn bootstrap_session( match resume_result { Ok(Ok(resume_response)) => { + apply_session_defaults( + connection.clone(), + acp::SessionId::new(existing_session_id.clone()), + ctx.default_mode.as_deref(), + ctx.default_model.as_deref(), + resume_response.config_options.as_ref(), + ) + .await; log::info!("ACP session resumed: {}", existing_session_id); client.set_session_id(existing_session_id.clone()).await; return Ok(SessionBootstrap { @@ -478,6 +520,14 @@ async fn bootstrap_session( }; log::info!("ACP session created: {}", session.session_id); + apply_session_defaults( + connection.clone(), + session.session_id.clone(), + ctx.default_mode.as_deref(), + ctx.default_model.as_deref(), + session.config_options.as_ref(), + ) + .await; client.set_session_id(session.session_id.to_string()).await; Ok(SessionBootstrap { @@ -491,7 +541,7 @@ async fn create_session( connection: Arc, cwd: PathBuf, ) -> Result, tokio::time::error::Elapsed> { - let session_request = acp::NewSessionRequest::new(cwd); + let session_request = new_session_request(cwd); tokio::time::timeout( std::time::Duration::from_secs(30), connection.new_session(session_request), @@ -504,7 +554,7 @@ async fn load_session( cwd: PathBuf, existing_session_id: String, ) -> Result, tokio::time::error::Elapsed> { - let request = acp::LoadSessionRequest::new(existing_session_id, cwd); + let request = load_session_request(existing_session_id, cwd); tokio::time::timeout( std::time::Duration::from_secs(30), connection.load_session(request), @@ -517,7 +567,7 @@ async fn resume_session( cwd: PathBuf, existing_session_id: String, ) -> Result, tokio::time::error::Elapsed> { - let request = acp::ResumeSessionRequest::new(existing_session_id, cwd); + let request = resume_session_request(existing_session_id, cwd); tokio::time::timeout( std::time::Duration::from_secs(30), connection.resume_session(request), @@ -525,6 +575,74 @@ async fn resume_session( .await } +fn new_session_request(cwd: PathBuf) -> acp::NewSessionRequest { + acp::NewSessionRequest::new(cwd) +} + +fn load_session_request(existing_session_id: String, cwd: PathBuf) -> acp::LoadSessionRequest { + acp::LoadSessionRequest::new(existing_session_id, cwd) +} + +fn resume_session_request(existing_session_id: String, cwd: PathBuf) -> acp::ResumeSessionRequest { + acp::ResumeSessionRequest::new(existing_session_id, cwd) +} + +async fn apply_session_defaults( + connection: Arc, + session_id: acp::SessionId, + default_mode: Option<&str>, + default_model: Option<&str>, + config_options: Option<&Vec>, +) { + if let Some(mode_id) = default_mode.filter(|mode| !mode.trim().is_empty()) + && let Err(error) = connection + .set_session_mode(acp::SetSessionModeRequest::new( + session_id.clone(), + mode_id.to_string(), + )) + .await + { + log::warn!("Failed to apply ACP default mode '{}': {}", mode_id, error); + } + + let Some(model_id) = default_model.filter(|model| !model.trim().is_empty()) else { + return; + }; + let Some(config_id) = model_config_option_id(config_options) else { + log::debug!( + "ACP default model '{}' configured, but the agent did not expose a model config option", + model_id + ); + return; + }; + + if let Err(error) = connection + .set_session_config_option(acp::SetSessionConfigOptionRequest::new( + session_id, + config_id, + model_id.to_string(), + )) + .await + { + log::warn!( + "Failed to apply ACP default model '{}': {}", + model_id, + error + ); + } +} + +fn model_config_option_id( + config_options: Option<&Vec>, +) -> Option { + config_options? + .iter() + .find(|option| { + option.id.to_string() == "model" || option.category.as_deref() == Some("model") + }) + .map(|option| option.id.to_string()) +} + fn map_mode_state(modes: acp::SessionModeState) -> SessionModeState { SessionModeState { current_mode_id: Some(modes.current_mode_id.to_string()), @@ -570,3 +688,49 @@ fn emit_initial_session_state( log::warn!("Failed to emit initial session config options: {}", e); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_session_request_sets_cwd() { + let request = new_session_request(PathBuf::from("/repo")); + + assert_eq!(request.cwd, PathBuf::from("/repo")); + assert!(request.mcp_servers.is_empty()); + } + + #[test] + fn load_session_request_sets_session_and_cwd() { + let request = load_session_request("session-1".to_string(), PathBuf::from("/repo")); + + assert_eq!(request.session_id, acp::SessionId::new("session-1")); + assert_eq!(request.cwd, PathBuf::from("/repo")); + assert!(request.mcp_servers.is_empty()); + } + + #[test] + fn resume_session_request_sets_session_and_cwd() { + let request = resume_session_request("session-1".to_string(), PathBuf::from("/repo")); + + assert_eq!(request.session_id, acp::SessionId::new("session-1")); + assert_eq!(request.cwd, PathBuf::from("/repo")); + assert!(request.mcp_servers.is_empty()); + } + + #[test] + fn qwen_launch_args_use_current_acp_flag() { + let mut config = AgentConfig::new("qwen-code", "Qwen Code", "qwen"); + config.args = vec![ + "--acp".to_string(), + "--experimental-skills".to_string(), + "--other".to_string(), + ]; + + assert_eq!( + launch_args(&config), + vec!["--experimental-acp".to_string(), "--other".to_string()] + ); + } +} diff --git a/crates/ai/src/acp/bridge_prompt.rs b/crates/ai/src/acp/bridge_prompt.rs index cd6f70017..3ad237bf9 100644 --- a/crates/ai/src/acp/bridge_prompt.rs +++ b/crates/ai/src/acp/bridge_prompt.rs @@ -72,7 +72,7 @@ async fn send_prompt_with_auth_retry( Ok(Err(err)) if matches!(err.code, acp::ErrorCode::AuthRequired) => { bail!("Authentication required before sending prompt") } - Ok(Err(err)) => Err(err).context("Failed to send prompt"), + Ok(Err(err)) => bail!("Failed to send prompt: {}", err), Err(_) => bail!("The ACP adapter did not acknowledge the prompt in time"), } } diff --git a/crates/ai/src/acp/client.rs b/crates/ai/src/acp/client.rs index 4b3ac6739..6695ea712 100644 --- a/crates/ai/src/acp/client.rs +++ b/crates/ai/src/acp/client.rs @@ -61,6 +61,51 @@ impl AthasAcpClient { self.permission_tx.clone() } + fn map_permission_option_kind( + kind: acp::PermissionOptionKind, + ) -> super::types::AcpPermissionOptionKind { + match kind { + acp::PermissionOptionKind::AllowOnce => super::types::AcpPermissionOptionKind::AllowOnce, + acp::PermissionOptionKind::AllowAlways => { + super::types::AcpPermissionOptionKind::AllowAlways + } + acp::PermissionOptionKind::RejectOnce => super::types::AcpPermissionOptionKind::RejectOnce, + acp::PermissionOptionKind::RejectAlways => { + super::types::AcpPermissionOptionKind::RejectAlways + } + _ => super::types::AcpPermissionOptionKind::RejectOnce, + } + } + + fn permission_response_for_choice( + options: &[acp::PermissionOption], + approved: bool, + ) -> acp::RequestPermissionResponse { + let selected_option = options + .iter() + .find(|opt| { + if approved { + matches!( + opt.kind, + acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways + ) + } else { + matches!( + opt.kind, + acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways + ) + } + }) + .or_else(|| options.first()) + .map(|opt| acp::SelectedPermissionOutcome::new(opt.option_id.clone())); + + if let Some(selected) = selected_option { + acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Selected(selected)) + } else { + acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Cancelled) + } + } + pub async fn set_session_id(&self, session_id: String) { let mut current = self.current_session_id.lock().await; *current = Some(session_id); @@ -209,23 +254,7 @@ impl AthasAcpClient { fn fallback_permission_response( args: &acp::RequestPermissionRequest, ) -> acp::RequestPermissionResponse { - let selected_option = args - .options - .iter() - .find(|opt| { - matches!( - opt.kind, - acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways - ) - }) - .or_else(|| args.options.first()) - .map(|opt| acp::SelectedPermissionOutcome::new(opt.option_id.clone())); - - if let Some(selected) = selected_option { - acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Selected(selected)) - } else { - acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Cancelled) - } + Self::permission_response_for_choice(&args.options, false) } fn map_plan_priority(priority: acp::PlanEntryPriority) -> AcpPlanEntryPriority { @@ -348,6 +377,14 @@ impl AthasAcpClient { .collect() } + fn map_usage_update(session_id: String, update: acp::UsageUpdate) -> AcpEvent { + AcpEvent::UsageUpdate { + session_id, + used: update.used, + size: update.size, + } + } + pub(crate) fn map_session_config_option( option: acp::SessionConfigOption, ) -> Option { @@ -418,21 +455,7 @@ impl acp::Client for AthasAcpClient { .map(|option| super::types::AcpPermissionOption { id: option.option_id.to_string(), name: option.name.clone(), - kind: match option.kind { - acp::PermissionOptionKind::AllowOnce => { - super::types::AcpPermissionOptionKind::AllowOnce - } - acp::PermissionOptionKind::AllowAlways => { - super::types::AcpPermissionOptionKind::AllowAlways - } - acp::PermissionOptionKind::RejectOnce => { - super::types::AcpPermissionOptionKind::RejectOnce - } - acp::PermissionOptionKind::RejectAlways => { - super::types::AcpPermissionOptionKind::RejectAlways - } - _ => super::types::AcpPermissionOptionKind::RejectOnce, - }, + kind: Self::map_permission_option_kind(option.kind), }) .collect(), }); @@ -486,53 +509,9 @@ impl acp::Client for AthasAcpClient { return Ok(Self::fallback_permission_response(&args)); } - // Prefer allow-once/allow-always options if available - let selected_option = args - .options - .iter() - .find(|opt| { - matches!( - opt.kind, - acp::PermissionOptionKind::AllowOnce - | acp::PermissionOptionKind::AllowAlways - ) - }) - .or_else(|| args.options.first()) - .map(|opt| acp::SelectedPermissionOutcome::new(opt.option_id.clone())); - - if let Some(selected) = selected_option { - Ok(acp::RequestPermissionResponse::new( - acp::RequestPermissionOutcome::Selected(selected), - )) - } else { - Ok(acp::RequestPermissionResponse::new( - acp::RequestPermissionOutcome::Cancelled, - )) - } + Ok(Self::permission_response_for_choice(&args.options, true)) } else { - // Prefer reject-once/reject-always options if available - let selected_option = args - .options - .iter() - .find(|opt| { - matches!( - opt.kind, - acp::PermissionOptionKind::RejectOnce - | acp::PermissionOptionKind::RejectAlways - ) - }) - .or_else(|| args.options.first()) - .map(|opt| acp::SelectedPermissionOutcome::new(opt.option_id.clone())); - - if let Some(selected) = selected_option { - Ok(acp::RequestPermissionResponse::new( - acp::RequestPermissionOutcome::Selected(selected), - )) - } else { - Ok(acp::RequestPermissionResponse::new( - acp::RequestPermissionOutcome::Cancelled, - )) - } + Ok(Self::permission_response_for_choice(&args.options, false)) } } _ => Ok(acp::RequestPermissionResponse::new( @@ -695,6 +674,9 @@ impl acp::Client for AthasAcpClient { updated_at: update.updated_at.take(), }); } + acp::SessionUpdate::UsageUpdate(update) => { + self.emit_event(Self::map_usage_update(session_id, update)); + } acp::SessionUpdate::AvailableCommandsUpdate(commands_update) => { self.emit_event(AcpEvent::SlashCommandsUpdate { session_id, @@ -1161,3 +1143,101 @@ impl acp::Client for AthasAcpClient { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn permission_option( + id: &'static str, + kind: acp::PermissionOptionKind, + ) -> acp::PermissionOption { + acp::PermissionOption::new(id, id, kind) + } + + fn selected_option_id(response: acp::RequestPermissionResponse) -> Option { + match response.outcome { + acp::RequestPermissionOutcome::Selected(selected) => Some(selected.option_id.to_string()), + acp::RequestPermissionOutcome::Cancelled => None, + _ => None, + } + } + + #[test] + fn usage_update_maps_to_frontend_event() { + let event = AthasAcpClient::map_usage_update( + "session-1".to_string(), + acp::UsageUpdate::new(1234, 200000), + ); + + match event { + AcpEvent::UsageUpdate { + session_id, + used, + size, + } => { + assert_eq!(session_id, "session-1"); + assert_eq!(used, 1234); + assert_eq!(size, 200000); + } + other => panic!("expected usage update event, got {other:?}"), + } + } + + #[test] + fn permission_option_kinds_map_to_frontend_kinds() { + assert!(matches!( + AthasAcpClient::map_permission_option_kind(acp::PermissionOptionKind::AllowOnce), + super::super::types::AcpPermissionOptionKind::AllowOnce + )); + assert!(matches!( + AthasAcpClient::map_permission_option_kind(acp::PermissionOptionKind::AllowAlways), + super::super::types::AcpPermissionOptionKind::AllowAlways + )); + assert!(matches!( + AthasAcpClient::map_permission_option_kind(acp::PermissionOptionKind::RejectOnce), + super::super::types::AcpPermissionOptionKind::RejectOnce + )); + assert!(matches!( + AthasAcpClient::map_permission_option_kind(acp::PermissionOptionKind::RejectAlways), + super::super::types::AcpPermissionOptionKind::RejectAlways + )); + } + + #[test] + fn approved_permission_prefers_allow_options() { + let options = vec![ + permission_option("reject-once", acp::PermissionOptionKind::RejectOnce), + permission_option("allow-always", acp::PermissionOptionKind::AllowAlways), + ]; + + let response = AthasAcpClient::permission_response_for_choice(&options, true); + + assert_eq!( + selected_option_id(response).as_deref(), + Some("allow-always") + ); + } + + #[test] + fn rejected_permission_prefers_reject_options() { + let options = vec![ + permission_option("allow-once", acp::PermissionOptionKind::AllowOnce), + permission_option("reject-always", acp::PermissionOptionKind::RejectAlways), + ]; + + let response = AthasAcpClient::permission_response_for_choice(&options, false); + + assert_eq!( + selected_option_id(response).as_deref(), + Some("reject-always") + ); + } + + #[test] + fn permission_choice_cancels_when_options_are_empty() { + let response = AthasAcpClient::permission_response_for_choice(&[], true); + + assert_eq!(selected_option_id(response), None); + } +} diff --git a/crates/ai/src/acp/config.rs b/crates/ai/src/acp/config.rs index 44cab9288..6955d25df 100644 --- a/crates/ai/src/acp/config.rs +++ b/crates/ai/src/acp/config.rs @@ -1,5 +1,6 @@ use super::types::AgentConfig; use crate::runtime::AthasAppHandle as AppHandle; +use serde::Deserialize; use std::{ collections::HashMap, env, fs, @@ -12,6 +13,7 @@ use tauri::Manager; /// Cache duration for binary detection (60 seconds) const DETECTION_CACHE_SECONDS: u64 = 60; +const SIGIT_WRAPPER_VERSION: u32 = 2; /// Get the user's login shell PATH. Bundled apps inherit a minimal PATH, /// so we source the full one from the user's shell and cache it. @@ -39,6 +41,7 @@ pub struct AgentRegistry { agents: HashMap, last_detection: Option, managed_bin_dir: Option, + managed_receipt_dir: Option, } impl AgentRegistry { @@ -47,6 +50,7 @@ impl AgentRegistry { agents: HashMap::new(), last_detection: None, managed_bin_dir: managed_acp_bin_dir(app_handle), + managed_receipt_dir: managed_acp_receipt_dir(app_handle), } } @@ -86,14 +90,26 @@ impl AgentRegistry { if let Some(path) = managed_wrapper_path(self.managed_bin_dir.as_deref(), &config.id) { config.installed = true; config.binary_path = Some(path.to_string_lossy().to_string()); + config.update_available = + managed_agent_needs_update(self.managed_receipt_dir.as_deref(), config); continue; } + config.update_available = false; + if config.id == "codex-cli" { detect_codex_adapter(config); continue; } + if let Some(path) = config.binary_path.as_ref().map(PathBuf::from) + && path.is_file() + { + config.installed = true; + config.binary_path = Some(path.to_string_lossy().to_string()); + continue; + } + if let Some(path) = find_binary(&config.binary_name) { config.installed = true; config.binary_path = Some(path.to_string_lossy().to_string()); @@ -128,15 +144,95 @@ fn managed_acp_bin_dir(app_handle: &AppHandle) -> Option { Some(data_dir.join("tools").join("acp")) } +fn managed_acp_receipt_dir(app_handle: &AppHandle) -> Option { + let data_dir = app_handle.path().app_data_dir().ok()?; + Some(data_dir.join("tools").join("acp").join(".receipts")) +} + +fn managed_agent_receipt_path(receipt_dir: Option<&Path>, agent_id: &str) -> Option { + let dir = receipt_dir?; + Some(dir.join(format!("{}.json", receipt_file_stem(agent_id)))) +} + +fn receipt_file_stem(agent_id: &str) -> String { + let stem = agent_id + .chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => character, + _ => '_', + }) + .collect::(); + if stem == "." || stem == ".." { + stem.replace('.', "_") + } else { + stem + } +} + +fn managed_agent_needs_update(receipt_dir: Option<&Path>, config: &AgentConfig) -> bool { + if !config.can_install { + return false; + } + + let Some(receipt_path) = managed_agent_receipt_path(receipt_dir, &config.id) else { + return false; + }; + let Ok(receipt_json) = fs::read_to_string(receipt_path) else { + return true; + }; + let Ok(receipt) = serde_json::from_str::(&receipt_json) else { + return true; + }; + + !receipt.matches(config) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManagedAgentReceipt { + install_runtime: Option, + install_package: Option, + install_download_url: Option, + install_command: Option, + wrapper_version: Option, +} + +impl ManagedAgentReceipt { + fn matches(&self, config: &AgentConfig) -> bool { + self.install_runtime == agent_runtime_key(config) + && self.install_package == config.install_package + && self.install_download_url == config.install_download_url + && self.install_command == config.install_command + && self.wrapper_version == managed_agent_wrapper_version(config) + } +} + +fn managed_agent_wrapper_version(config: &AgentConfig) -> Option { + if config.id == "sigit" { + Some(SIGIT_WRAPPER_VERSION) + } else { + None + } +} + +fn agent_runtime_key(config: &AgentConfig) -> Option { + config + .install_runtime + .as_ref() + .and_then(|runtime| serde_json::to_value(runtime).ok()) + .and_then(|value| value.as_str().map(ToString::to_string)) +} + fn wrapper_file_name(agent_id: &str) -> String { + let safe_agent_id = receipt_file_stem(agent_id); #[cfg(target_os = "windows")] { - format!("{agent_id}.cmd") + format!("{safe_agent_id}.cmd") } #[cfg(not(target_os = "windows"))] { - agent_id.to_string() + safe_agent_id } } @@ -150,19 +246,10 @@ fn detect_codex_adapter(config: &mut AgentConfig) { return; } - // Fallback to npx for users who haven't installed codex-acp globally yet. - if let Some(path) = find_binary("npx") { - config.installed = true; - config.binary_path = Some(path.to_string_lossy().to_string()); - config.args = vec!["-y".to_string(), "@zed-industries/codex-acp".to_string()]; - log::debug!("Using npx fallback for codex-acp at {}", path.display()); - return; - } - config.installed = false; config.binary_path = None; config.args.clear(); - log::debug!("Codex ACP adapter not found (neither codex-acp nor npx available)"); + log::debug!("Codex ACP adapter not found"); } fn find_binary(binary_name: &str) -> Option { @@ -317,7 +404,8 @@ fn check_dir_for_binary(dir: &Path, binary_name: &str) -> Option { #[cfg(test)] mod tests { - use super::{check_dir_for_binary, managed_wrapper_path}; + use super::{check_dir_for_binary, managed_agent_needs_update, managed_wrapper_path}; + use crate::acp::types::{AgentConfig, AgentRuntime}; use std::{fs, path::PathBuf}; #[test] @@ -335,9 +423,94 @@ mod tests { assert_eq!(resolved, wrapper); } + #[test] + fn managed_wrapper_path_sanitizes_agent_id() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let wrapper = if cfg!(windows) { + temp_dir.path().join(".._escape.cmd") + } else { + temp_dir.path().join(".._escape") + }; + fs::write(&wrapper, "echo test").expect("write wrapper"); + + let resolved = + managed_wrapper_path(Some(temp_dir.path()), "../escape").expect("wrapper should exist"); + assert_eq!(resolved, wrapper); + + let dot_dot = if cfg!(windows) { + temp_dir.path().join("__.cmd") + } else { + temp_dir.path().join("__") + }; + fs::write(&dot_dot, "echo test").expect("write wrapper"); + let resolved_dot_dot = + managed_wrapper_path(Some(temp_dir.path()), "..").expect("wrapper should exist"); + assert_eq!(resolved_dot_dot, dot_dot); + } + #[test] fn check_dir_for_binary_returns_none_for_missing_binary() { let missing = check_dir_for_binary(PathBuf::from("/tmp/athas-missing").as_path(), "nope"); assert!(missing.is_none()); } + + #[test] + fn missing_managed_receipt_marks_install_update_available() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let mut config = AgentConfig::new("amp-acp", "Amp", "amp-acp"); + config.install_runtime = Some(AgentRuntime::Binary); + config.install_package = Some("./amp-acp".to_string()); + config.install_download_url = Some("https://example.com/amp-v1.tar.gz".to_string()); + config.install_command = Some("amp-acp".to_string()); + config.can_install = true; + + assert!(managed_agent_needs_update(Some(temp_dir.path()), &config)); + } + + #[test] + fn stale_managed_receipt_marks_install_update_available() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + fs::write( + temp_dir.path().join("amp-acp.json"), + r#"{ + "installRuntime": "binary", + "installPackage": "./amp-acp", + "installDownloadUrl": "https://example.com/amp-v1.tar.gz", + "installCommand": "amp-acp" +}"#, + ) + .expect("write receipt"); + + let mut config = AgentConfig::new("amp-acp", "Amp", "amp-acp"); + config.install_runtime = Some(AgentRuntime::Binary); + config.install_package = Some("./amp-acp".to_string()); + config.install_download_url = Some("https://example.com/amp-v2.tar.gz".to_string()); + config.install_command = Some("amp-acp".to_string()); + config.can_install = true; + + assert!(managed_agent_needs_update(Some(temp_dir.path()), &config)); + } + + #[test] + fn current_managed_receipt_keeps_install_current() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + fs::write( + temp_dir.path().join("codex-acp.json"), + r#"{ + "installRuntime": "node", + "installPackage": "@vendor/codex-acp@1.0.0", + "installDownloadUrl": null, + "installCommand": "codex-acp" +}"#, + ) + .expect("write receipt"); + + let mut config = AgentConfig::new("codex-acp", "Codex", "codex-acp"); + config.install_runtime = Some(AgentRuntime::Node); + config.install_package = Some("@vendor/codex-acp@1.0.0".to_string()); + config.install_command = Some("codex-acp".to_string()); + config.can_install = true; + + assert!(!managed_agent_needs_update(Some(temp_dir.path()), &config)); + } } diff --git a/crates/ai/src/acp/types.rs b/crates/ai/src/acp/types.rs index c6e9af22a..6972fb00f 100644 --- a/crates/ai/src/acp/types.rs +++ b/crates/ai/src/acp/types.rs @@ -204,6 +204,8 @@ pub struct AgentConfig { pub binary_path: Option, pub args: Vec, pub env_vars: HashMap, + pub default_mode: Option, + pub default_model: Option, pub icon: Option, pub description: Option, pub installed: bool, @@ -213,6 +215,8 @@ pub struct AgentConfig { pub install_download_url: Option, pub install_command: Option, pub can_install: bool, + #[serde(default)] + pub update_available: bool, } impl AgentConfig { @@ -224,6 +228,8 @@ impl AgentConfig { binary_path: None, args: Vec::new(), env_vars: HashMap::new(), + default_mode: None, + default_model: None, icon: None, description: None, installed: false, @@ -232,6 +238,7 @@ impl AgentConfig { install_download_url: None, install_command: None, can_install: false, + update_available: false, } } @@ -370,6 +377,13 @@ pub struct AcpSessionList { pub next_cursor: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpErrorKind { + AuthenticationRequired, + ProviderSetupRequired, +} + /// Events emitted to the frontend via Tauri #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -445,6 +459,7 @@ pub enum AcpEvent { Error { session_id: Option, error: String, + error_kind: Option, }, /// Agent status changed #[serde(rename_all = "camelCase")] @@ -486,6 +501,13 @@ pub enum AcpEvent { title: Option, updated_at: Option, }, + /// Session token/context usage updated + #[serde(rename_all = "camelCase")] + UsageUpdate { + session_id: String, + used: u64, + size: u64, + }, /// Prompt turn completed with a stop reason #[serde(rename_all = "camelCase")] PromptComplete { diff --git a/crates/tooling/src/installer.rs b/crates/tooling/src/installer.rs index 84cdd6073..d5c0739aa 100644 --- a/crates/tooling/src/installer.rs +++ b/crates/tooling/src/installer.rs @@ -104,7 +104,7 @@ impl ToolInstaller { } fn known_node_companion_packages(package: &str) -> &'static [&'static str] { - match package { + match Self::node_package_name(package).as_str() { // typescript-language-server declares TypeScript as a peer dependency // and exits during LSP initialize if it cannot resolve it locally. "typescript-language-server" => &["typescript"], @@ -129,6 +129,52 @@ impl ToolInstaller { packages } + pub fn managed_dir_name(input: &str) -> String { + let name = input + .chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '@' => character, + _ => '_', + }) + .collect::(); + if name.is_empty() { + "tool".to_string() + } else if name == "." || name == ".." { + name.replace('.', "_") + } else { + name + } + } + + fn node_package_name(package_spec: &str) -> String { + let spec = package_spec.trim(); + if let Some(scoped) = spec.strip_prefix('@') { + let mut segments = scoped.splitn(3, '/'); + let Some(scope) = segments.next() else { + return spec.to_string(); + }; + let Some(name_and_version) = segments.next() else { + return spec.to_string(); + }; + let name = name_and_version + .rsplit_once('@') + .map(|(name, _)| name) + .unwrap_or(name_and_version); + if name.is_empty() { + spec.to_string() + } else { + format!("@{scope}/{name}") + } + } else { + spec + .rsplit_once('@') + .map(|(name, _)| name) + .filter(|name| !name.is_empty()) + .unwrap_or(spec) + .to_string() + } + } + fn node_companion_packages_to_validate( package: &str, companion_packages: &[String], @@ -219,7 +265,9 @@ impl ToolInstaller { package: &str, command_name: &str, ) -> Option { - let package_root = package_dir.join("node_modules").join(package); + let package_root = package_dir + .join("node_modules") + .join(Self::node_package_name(package)); Self::resolve_node_package_entrypoint_from_root(&package_root, command_name, true) .filter(|path| path.exists()) } @@ -458,7 +506,9 @@ impl ToolInstaller { } fn binary_install_dir(app_handle: &AppHandle, name: &str) -> Result { - Ok(Self::get_tools_dir(app_handle)?.join("binary").join(name)) + Ok(Self::get_tools_dir(app_handle)? + .join("binary") + .join(Self::managed_dir_name(name))) } fn install_extracted_binary( @@ -535,7 +585,13 @@ impl ToolInstaller { .package .as_ref() .ok_or_else(|| ToolError::ConfigError("No package specified".to_string()))?; - Self::install_via_pip(app_handle, package, Self::configured_command_name(config)).await + Self::install_via_pip( + app_handle, + package, + Self::configured_command_name(config), + &config.packages, + ) + .await } ToolRuntime::Go => { let package = config @@ -603,7 +659,7 @@ impl ToolInstaller { .map_err(|e| ToolError::RuntimeNotAvailable(e.to_string()))?; let tools_dir = Self::get_tools_dir(app_handle)?; - let package_dir = tools_dir.join("bun").join(package); + let package_dir = tools_dir.join("bun").join(Self::managed_dir_name(package)); std::fs::create_dir_all(&package_dir)?; Self::ensure_node_package_manifest(&package_dir)?; @@ -652,7 +708,7 @@ impl ToolInstaller { .map_err(|e| ToolError::RuntimeNotAvailable(e.to_string()))?; let tools_dir = Self::get_tools_dir(app_handle)?; - let package_dir = tools_dir.join("npm").join(package); + let package_dir = tools_dir.join("npm").join(Self::managed_dir_name(package)); std::fs::create_dir_all(&package_dir)?; Self::ensure_node_package_manifest(&package_dir)?; @@ -699,6 +755,7 @@ impl ToolInstaller { app_handle: &AppHandle, package: &str, command_name: &str, + companion_packages: &[String], ) -> Result { let runtime_root = Self::get_runtime_root(app_handle)?; let python_path = RuntimeManager::get_runtime(Some(&runtime_root), RuntimeType::Python) @@ -706,7 +763,9 @@ impl ToolInstaller { .map_err(|e| ToolError::RuntimeNotAvailable(e.to_string()))?; let tools_dir = Self::get_tools_dir(app_handle)?; - let venv_dir = tools_dir.join("python").join(package); + let venv_dir = tools_dir + .join("python") + .join(Self::managed_dir_name(package)); std::fs::create_dir_all(&venv_dir)?; log::info!( @@ -737,9 +796,14 @@ impl ToolInstaller { venv_dir.join("bin").join("pip") }; + let mut packages = Vec::with_capacity(1 + companion_packages.len()); + packages.push(package); + packages.extend(companion_packages.iter().map(String::as_str)); + let mut command = Command::new(&pip_path); let output = configure_background_command(&mut command) - .args(["install", package]) + .arg("install") + .args(packages) .output() .map_err(|e| ToolError::InstallationFailed(e.to_string()))?; @@ -931,7 +995,7 @@ impl ToolInstaller { })?; let tools_dir = Self::get_tools_dir(app_handle)?; - let package_dir = tools_dir.join("ruby").join(package); + let package_dir = tools_dir.join("ruby").join(Self::managed_dir_name(package)); let gem_home = package_dir.join("gems"); let gem_bin_dir = package_dir.join("gem-bin"); std::fs::create_dir_all(&gem_home)?; @@ -1046,7 +1110,7 @@ impl ToolInstaller { .package .as_ref() .ok_or_else(|| ToolError::ConfigError("No package specified".to_string()))?; - let package_dir = tools_dir.join("bun").join(package); + let package_dir = tools_dir.join("bun").join(Self::managed_dir_name(package)); Self::validate_node_companion_packages(&package_dir, package, &config.packages)?; Ok( Self::resolve_node_package_binary(&package_dir, package, command_name) @@ -1064,7 +1128,7 @@ impl ToolInstaller { .package .as_ref() .ok_or_else(|| ToolError::ConfigError("No package specified".to_string()))?; - let package_dir = tools_dir.join("npm").join(package); + let package_dir = tools_dir.join("npm").join(Self::managed_dir_name(package)); Self::validate_node_companion_packages(&package_dir, package, &config.packages)?; Ok( Self::resolve_node_package_binary(&package_dir, package, command_name) @@ -1085,7 +1149,7 @@ impl ToolInstaller { let scripts_dir = if cfg!(windows) { "Scripts" } else { "bin" }; Ok(tools_dir .join("python") - .join(package) + .join(Self::managed_dir_name(package)) .join(scripts_dir) .join(bin_name)) } @@ -1103,7 +1167,7 @@ impl ToolInstaller { .as_ref() .ok_or_else(|| ToolError::ConfigError("No package specified".to_string()))?; Ok(Self::ruby_wrapper_path( - &tools_dir.join("ruby").join(package), + &tools_dir.join("ruby").join(Self::managed_dir_name(package)), Self::configured_command_name(config), )) } @@ -1117,7 +1181,9 @@ impl ToolInstaller { return Ok(system_path); } - let install_dir = tools_dir.join("binary").join(&config.name); + let install_dir = tools_dir + .join("binary") + .join(Self::managed_dir_name(&config.name)); if install_dir.exists() && let Ok(path) = Self::pick_binary(&install_dir, command_name) { @@ -1148,7 +1214,7 @@ impl ToolInstaller { .package .as_ref() .ok_or_else(|| ToolError::ConfigError("No package specified".to_string()))?; - let package_dir = tools_dir.join("bun").join(package); + let package_dir = tools_dir.join("bun").join(Self::managed_dir_name(package)); Self::validate_node_companion_packages(&package_dir, package, &config.packages)?; if let Some(entrypoint) = @@ -1172,7 +1238,7 @@ impl ToolInstaller { .package .as_ref() .ok_or_else(|| ToolError::ConfigError("No package specified".to_string()))?; - let package_dir = tools_dir.join("npm").join(package); + let package_dir = tools_dir.join("npm").join(Self::managed_dir_name(package)); Self::validate_node_companion_packages(&package_dir, package, &config.packages)?; if let Some(entrypoint) = @@ -1304,6 +1370,39 @@ mod tests { assert!(ready.is_ok()); } + #[test] + fn managed_dir_name_rejects_path_components() { + assert_eq!( + ToolInstaller::managed_dir_name("@scope/package@1.2.3"), + "@scope_package@1.2.3" + ); + assert_eq!( + ToolInstaller::managed_dir_name("../../Library/LaunchAgents/demo"), + ".._.._Library_LaunchAgents_demo" + ); + assert_eq!( + ToolInstaller::managed_dir_name("/tmp/absolute"), + "_tmp_absolute" + ); + assert_eq!(ToolInstaller::managed_dir_name(".."), "__"); + } + + #[test] + fn resolves_node_package_name_from_versioned_specs() { + assert_eq!( + ToolInstaller::node_package_name("typescript-language-server@4.4.1"), + "typescript-language-server" + ); + assert_eq!( + ToolInstaller::node_package_name("@vtsls/language-server@0.2.9"), + "@vtsls/language-server" + ); + assert_eq!( + ToolInstaller::node_package_name("@vtsls/language-server"), + "@vtsls/language-server" + ); + } + #[test] fn resolves_node_bin_shim_when_present() { let temp = tempfile::tempdir().unwrap(); @@ -1358,6 +1457,41 @@ mod tests { assert_eq!(resolved.as_deref(), Some(entrypoint.as_path())); } + #[test] + fn resolves_versioned_node_package_entrypoint() { + let temp = tempfile::tempdir().unwrap(); + let package_dir = temp + .path() + .join("npm") + .join("@agentclientprotocol") + .join("claude-agent-acp@0.33.1"); + let package_root = package_dir + .join("node_modules") + .join("@agentclientprotocol") + .join("claude-agent-acp"); + let entrypoint = package_root.join("bin").join("claude-agent-acp.js"); + fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); + fs::write( + package_root.join("package.json"), + r#"{ + "name": "@agentclientprotocol/claude-agent-acp", + "bin": { + "claude-agent-acp": "./bin/claude-agent-acp.js" + } +}"#, + ) + .unwrap(); + fs::write(&entrypoint, "").unwrap(); + + let resolved = ToolInstaller::resolve_node_package_binary( + &package_dir, + "@agentclientprotocol/claude-agent-acp@0.33.1", + "claude-agent-acp", + ); + + assert_eq!(resolved.as_deref(), Some(entrypoint.as_path())); + } + #[test] fn writes_ruby_wrapper_for_managed_gem_executable() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/tooling/src/registry.rs b/crates/tooling/src/registry.rs index 8c79d3346..ab0638550 100644 --- a/crates/tooling/src/registry.rs +++ b/crates/tooling/src/registry.rs @@ -296,8 +296,7 @@ mod tests { #[test] fn resolves_url_placeholders() { - let template = - "https://example.com/${os}/${arch}/${platformArch}/${targetOs}/${targetArch}.${archiveExt}?v=${version}"; + let template = "https://example.com/${os}/${arch}/${platformArch}/${targetOs}/${targetArch}.${archiveExt}?v=${version}"; let resolved = ToolRegistry::resolve_url_template(template); assert!(!resolved.contains("${")); diff --git a/src-tauri/src/commands/ai/acp.rs b/src-tauri/src/commands/ai/acp.rs index 7f0021d9b..5cdd33a02 100644 --- a/src-tauri/src/commands/ai/acp.rs +++ b/src-tauri/src/commands/ai/acp.rs @@ -2,20 +2,25 @@ use crate::app_runtime::AppHandle; use athas_ai::{AcpAgentBridge, AcpAgentStatus, AcpSessionList, AgentConfig, AgentRuntime}; use athas_runtime::{RuntimeManager, RuntimeType}; use athas_tooling::{ToolConfig, ToolInstaller, ToolRuntime}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fs, path::{Path, PathBuf}, sync::Arc, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tauri::{Manager, State}; +use tauri_plugin_store::StoreExt; use tokio::sync::Mutex; pub type AcpBridgeState = Arc>; const EXTENSIONS_CDN_BASE_URL: &str = "https://athas.dev/extensions"; +const ACP_REGISTRY_URL: &str = + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; const AGENT_CATALOG_CACHE_SECONDS: u64 = 300; +const EXCLUDED_ACP_REGISTRY_AGENT_IDS: &[&str] = &["agoragentic-acp"]; +const SIGIT_WRAPPER_VERSION: u32 = 2; #[derive(Deserialize)] pub struct PermissionResponseArgs { @@ -30,15 +35,17 @@ pub struct PermissionResponseArgs { #[tauri::command] pub async fn get_available_agents( + app_handle: AppHandle, bridge: State<'_, AcpBridgeState>, ) -> Result, String> { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; Ok(bridge.detect_agents()) } #[tauri::command] pub async fn start_acp_agent( + app_handle: AppHandle, bridge: State<'_, AcpBridgeState>, agent_id: String, workspace_path: Option, @@ -46,7 +53,7 @@ pub async fn start_acp_agent( ) -> Result { let bridge = { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; bridge.detect_agents(); bridge.clone() }; @@ -64,7 +71,7 @@ pub async fn install_acp_agent( ) -> Result { let agent = { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; let agents = bridge.detect_agents(); agents .into_iter() @@ -97,7 +104,7 @@ pub async fn uninstall_acp_agent( ) -> Result { let agent = { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; bridge.invalidate_agent_detection_cache(); let agents = bridge.detect_agents(); agents @@ -108,6 +115,7 @@ pub async fn uninstall_acp_agent( let tool_config = tool_config_from_agent(&agent)?; remove_acp_wrapper(&app_handle, &agent.id)?; + remove_acp_install_receipt(&app_handle, &agent.id)?; remove_managed_tool(&app_handle, &tool_config)?; let mut bridge = bridge.lock().await; @@ -163,12 +171,81 @@ struct MarketplaceExtensionManifest { agents: Vec, } +#[derive(Deserialize)] +struct AcpRegistryIndex { + #[serde(default)] + agents: Vec, +} + +#[derive(Deserialize)] +struct AcpRegistryAgent { + id: String, + name: String, + description: String, + icon: Option, + distribution: AcpRegistryDistribution, +} + +#[derive(Deserialize)] +struct AcpRegistryDistribution { + binary: Option>, + npx: Option, + uvx: Option, +} + +#[derive(Deserialize)] +struct AcpRegistryBinaryTarget { + archive: String, + cmd: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +#[derive(Deserialize)] +struct AcpRegistryPackageTarget { + package: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +enum AcpAgentServerSetting { + Custom { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + #[serde(default, alias = "defaultMode")] + default_mode: Option, + #[serde(default, alias = "defaultModel")] + default_model: Option, + }, + Registry { + #[serde(default)] + env: HashMap, + #[serde(default, alias = "defaultMode")] + default_mode: Option, + #[serde(default, alias = "defaultModel")] + default_model: Option, + }, +} + fn extensions_manifest_url() -> String { let base_url = std::env::var("ATHAS_EXTENSIONS_CDN_URL") .unwrap_or_else(|_| EXTENSIONS_CDN_BASE_URL.to_string()); format!("{}/manifests.json", base_url.trim_end_matches('/')) } +fn acp_registry_url() -> String { + std::env::var("ATHAS_ACP_REGISTRY_URL").unwrap_or_else(|_| ACP_REGISTRY_URL.to_string()) +} + fn current_platform_arch() -> Option<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => Some("darwin-arm64"), @@ -181,14 +258,150 @@ fn current_platform_arch() -> Option<&'static str> { } } +fn current_acp_registry_platform() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Some("darwin-aarch64"), + ("macos", "x86_64") => Some("darwin-x86_64"), + ("linux", "aarch64") => Some("linux-aarch64"), + ("linux", "x86_64") => Some("linux-x86_64"), + ("windows", "aarch64") => Some("windows-aarch64"), + ("windows", "x86_64") => Some("windows-x86_64"), + _ => None, + } +} + +fn registry_command_name(cmd: &str, fallback: &str) -> String { + Path::new(cmd) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| { + if cfg!(windows) { + name.strip_suffix(".exe").unwrap_or(name).to_string() + } else { + name.to_string() + } + }) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn npm_package_name(package_spec: &str) -> String { + let spec = package_spec.trim(); + if let Some(scoped) = spec.strip_prefix('@') { + let mut segments = scoped.splitn(3, '/'); + let Some(scope) = segments.next() else { + return spec.to_string(); + }; + let Some(name_and_version) = segments.next() else { + return spec.to_string(); + }; + let name = name_and_version + .rsplit_once('@') + .map(|(name, _)| name) + .unwrap_or(name_and_version); + if name.is_empty() { + spec.to_string() + } else { + format!("@{scope}/{name}") + } + } else { + spec + .rsplit_once('@') + .map(|(name, _)| name) + .filter(|name| !name.is_empty()) + .unwrap_or(spec) + .to_string() + } +} + +fn package_command_name(package_name: &str, fallback: &str) -> String { + package_name + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn npx_command_name(package_name: &str, fallback: &str) -> String { + match package_name { + "@google/gemini-cli" => "gemini".to_string(), + "@qwen-code/qwen-code" => "qwen".to_string(), + "@tencent-ai/codebuddy-code" => "codebuddy".to_string(), + "dirac-cli" => "dirac".to_string(), + _ => package_command_name(package_name, fallback), + } +} + +fn python_package_spec_from_uvx(package_spec: &str) -> String { + let spec = package_spec.trim(); + let package = if spec.contains("==") { + spec.to_string() + } else { + spec + .rsplit_once('@') + .map(|(package, version)| format!("{package}=={version}")) + .unwrap_or_else(|| spec.to_string()) + }; + + package + .strip_prefix("fast-agent-acp==") + .map(|version| format!("fast-agent-mcp=={version}")) + .unwrap_or(package) +} + +fn python_command_name(package_spec: &str, fallback: &str) -> String { + let package = package_spec + .split_once("==") + .map(|(package, _)| package) + .unwrap_or(package_spec) + .trim(); + if package == "fast-agent-mcp" { + return "fast-agent-acp".to_string(); + } + package_command_name(package, fallback) +} + +fn registry_agent_args(agent_id: &str, mut args: Vec) -> Vec { + if agent_id == "qwen-code" { + args = args + .into_iter() + .filter(|arg| arg != "--experimental-skills") + .map(|arg| { + if arg == "--acp" || arg == "acp" { + "--experimental-acp".to_string() + } else { + arg + } + }) + .collect(); + if !args.iter().any(|arg| arg == "--experimental-acp") { + args.push("--experimental-acp".to_string()); + } + } + + args +} + +fn acp_python_companion_packages(agent_id: &str) -> Vec { + if agent_id == "minion-code" { + vec!["agent-client-protocol<0.9".to_string()] + } else { + Vec::new() + } +} + fn to_agent_config(contribution: MarketplaceAgentContribution) -> AgentConfig { + let args = registry_agent_args(&contribution.id, contribution.args); let mut agent = AgentConfig { id: contribution.id, name: contribution.name, binary_name: contribution.binary_name, binary_path: None, - args: contribution.args, + args, env_vars: contribution.env_vars, + default_mode: None, + default_model: None, icon: contribution.icon, description: contribution.description, installed: false, @@ -197,6 +410,7 @@ fn to_agent_config(contribution: MarketplaceAgentContribution) -> AgentConfig { install_download_url: None, install_command: None, can_install: false, + update_available: false, }; if let Some(install) = contribution.install { @@ -217,7 +431,325 @@ fn to_agent_config(contribution: MarketplaceAgentContribution) -> AgentConfig { agent } -async fn load_marketplace_agents() -> Result, String> { +fn acp_registry_agent_to_config(agent: AcpRegistryAgent) -> Option { + let AcpRegistryAgent { + id, + name, + description, + icon, + distribution, + } = agent; + + if let Some(target) = current_acp_registry_platform() + .and_then(|platform| distribution.binary.as_ref()?.get(platform)) + { + let binary_name = registry_command_name(&target.cmd, &id); + let args = registry_agent_args(&id, target.args.clone()); + return Some(AgentConfig { + id, + name, + binary_name, + binary_path: None, + args, + env_vars: target.env.clone(), + default_mode: None, + default_model: None, + icon, + description: Some(description), + installed: false, + install_runtime: Some(AgentRuntime::Binary), + install_package: Some(target.cmd.clone()), + install_download_url: Some(target.archive.clone()), + install_command: Some(registry_command_name(&target.cmd, "")), + can_install: true, + update_available: false, + }); + } + + if let Some(target) = distribution.npx { + let package_name = npm_package_name(&target.package); + let command = npx_command_name(&package_name, &id); + let args = registry_agent_args(&id, target.args.clone()); + return Some(AgentConfig { + id, + name, + binary_name: command.clone(), + binary_path: None, + args, + env_vars: target.env, + default_mode: None, + default_model: None, + icon, + description: Some(description), + installed: false, + install_runtime: Some(AgentRuntime::Node), + install_package: Some(target.package), + install_download_url: None, + install_command: Some(command), + can_install: true, + update_available: false, + }); + } + + if let Some(target) = distribution.uvx { + let package = python_package_spec_from_uvx(&target.package); + let command = python_command_name(&package, &id); + let args = registry_agent_args(&id, target.args); + return Some(AgentConfig { + id, + name, + binary_name: command.clone(), + binary_path: None, + args, + env_vars: target.env, + default_mode: None, + default_model: None, + icon, + description: Some(description), + installed: false, + install_runtime: Some(AgentRuntime::Python), + install_package: Some(package), + install_download_url: None, + install_command: Some(command), + can_install: true, + update_available: false, + }); + } + + None +} + +fn acp_registry_agents_from_index(index: AcpRegistryIndex) -> Vec { + let mut agents = index + .agents + .into_iter() + .filter(|agent| !EXCLUDED_ACP_REGISTRY_AGENT_IDS.contains(&agent.id.as_str())) + .filter_map(acp_registry_agent_to_config) + .collect::>(); + agents.sort_by_key(|agent| agent.name.clone()); + agents +} + +fn acp_registry_cache_path(app_handle: &AppHandle) -> Result { + app_handle + .path() + .app_data_dir() + .map(|dir| dir.join("acp-registry").join("registry.json")) + .map_err(|error| format!("Failed to resolve ACP registry cache path: {}", error)) +} + +fn acp_registry_agents_from_json(json: &str) -> Result, String> { + let registry = serde_json::from_str::(json) + .map_err(|error| format!("Invalid ACP registry: {}", error))?; + Ok(acp_registry_agents_from_index(registry)) +} + +fn load_cached_acp_registry_agents(app_handle: &AppHandle) -> Result, String> { + let cache_path = acp_registry_cache_path(app_handle)?; + let json = fs::read_to_string(&cache_path) + .map_err(|error| format!("Failed to read cached ACP registry: {}", error))?; + acp_registry_agents_from_json(&json) + .map_err(|error| format!("Invalid cached ACP registry: {}", error)) +} + +fn write_acp_registry_cache(app_handle: &AppHandle, json: &str) -> Result<(), String> { + let cache_path = acp_registry_cache_path(app_handle)?; + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create ACP registry cache directory: {}", error))?; + } + fs::write(&cache_path, json) + .map_err(|error| format!("Failed to write ACP registry cache: {}", error)) +} + +async fn load_acp_registry_agents(app_handle: &AppHandle) -> Result, String> { + let response = reqwest::Client::new() + .get(acp_registry_url()) + .timeout(Duration::from_secs(5)) + .send() + .await + .map_err(|error| format!("Failed to load ACP registry: {}", error))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to load ACP registry: HTTP {}", + response.status() + )); + } + + let json = response + .text() + .await + .map_err(|error| format!("Failed to read ACP registry response: {}", error))?; + + let agents = acp_registry_agents_from_json(&json)?; + if let Err(error) = write_acp_registry_cache(app_handle, &json) { + log::warn!("{}", error); + } + + Ok(agents) +} + +async fn load_preferred_registry_agents( + app_handle: &AppHandle, +) -> Result, String> { + match load_acp_registry_agents(app_handle).await { + Ok(agents) => Ok(agents), + Err(registry_error) => { + log::warn!("{}", registry_error); + load_cached_acp_registry_agents(app_handle).map_err(|cache_error| { + log::warn!("{}", cache_error); + registry_error + }) + } + } +} + +fn merge_agent_catalogs( + mut preferred_agents: Vec, + fallback_agents: Vec, +) -> Vec { + for agent in fallback_agents { + if !preferred_agents + .iter() + .any(|preferred| agents_refer_to_same_provider(preferred, &agent)) + { + preferred_agents.push(agent); + } + } + preferred_agents.sort_by_key(|agent| agent.name.clone()); + preferred_agents +} + +fn agents_refer_to_same_provider(preferred: &AgentConfig, fallback: &AgentConfig) -> bool { + preferred.id == fallback.id + || normalized_agent_name(&preferred.name) == normalized_agent_name(&fallback.name) +} + +fn normalized_agent_name(name: &str) -> String { + name + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(|character| character.to_lowercase()) + .collect() +} + +fn apply_agent_server_settings( + mut agents: Vec, + settings: HashMap, +) -> Vec { + for (id, setting) in settings { + match setting { + AcpAgentServerSetting::Custom { + command, + args, + env, + default_mode, + default_model, + } => { + if command.trim().is_empty() { + log::warn!("Skipping custom ACP agent '{}' with an empty command", id); + continue; + } + let binary_name = registry_command_name(&command, &id); + let custom_agent = AgentConfig { + id: id.clone(), + name: id, + binary_name, + binary_path: Some(expand_home(&command)), + args, + env_vars: env, + default_mode: clean_setting(default_mode), + default_model: clean_setting(default_model), + icon: None, + description: Some("Custom ACP agent from Athas settings".to_string()), + installed: false, + install_runtime: None, + install_package: None, + install_download_url: None, + install_command: None, + can_install: false, + update_available: false, + }; + upsert_agent(&mut agents, custom_agent); + } + AcpAgentServerSetting::Registry { + env, + default_mode, + default_model, + } => { + let Some(agent) = agents + .iter_mut() + .find(|agent| agent.id == id || agent.name == id) + else { + log::debug!("Configured ACP registry agent '{}' was not found", id); + continue; + }; + agent.env_vars.extend(env); + agent.default_mode = clean_setting(default_mode); + agent.default_model = clean_setting(default_model); + } + } + } + + agents.sort_by_key(|agent| agent.name.clone()); + agents +} + +fn clean_setting(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn expand_home(path: &str) -> String { + if path == "~" { + return std::env::var("HOME").unwrap_or_else(|_| path.to_string()); + } + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return Path::new(&home).join(rest).to_string_lossy().to_string(); + } + } + path.to_string() +} + +fn upsert_agent(agents: &mut Vec, agent: AgentConfig) { + if let Some(existing) = agents.iter_mut().find(|existing| existing.id == agent.id) { + *existing = agent; + } else { + agents.push(agent); + } +} + +fn load_agent_server_settings( + app_handle: &AppHandle, +) -> Result, String> { + let store = app_handle + .store("settings.json") + .map_err(|error| format!("Failed to load settings store: {}", error))?; + let Some(value) = store.get("agentServers") else { + return Ok(HashMap::new()); + }; + let raw_settings = serde_json::from_value::>(value) + .map_err(|error| format!("Invalid agentServers settings: {}", error))?; + let mut settings = HashMap::new(); + + for (id, value) in raw_settings { + match serde_json::from_value::(value) { + Ok(setting) => { + settings.insert(id, setting); + } + Err(error) => { + log::warn!("Skipping invalid ACP agent setting '{}': {}", id, error); + } + } + } + + Ok(settings) +} + +async fn load_marketplace_agents(app_handle: &AppHandle) -> Result, String> { let cache = AGENT_CATALOG_CACHE.get_or_init(|| std::sync::Mutex::new(None)); { let cached = cache @@ -226,10 +758,55 @@ async fn load_marketplace_agents() -> Result, String> { if let Some(catalog) = cached.as_ref() && catalog.loaded_at.elapsed() < Duration::from_secs(AGENT_CATALOG_CACHE_SECONDS) { - return Ok(catalog.agents.clone()); + let agent_settings = load_agent_server_settings(app_handle).map_err(|error| { + log::warn!("{}", error); + error + })?; + return Ok(apply_agent_server_settings( + catalog.agents.clone(), + agent_settings, + )); } } + let registry_agents = load_preferred_registry_agents(app_handle).await; + let legacy_agents = load_legacy_marketplace_agents().await; + let agents = match (registry_agents, legacy_agents) { + (Ok(registry_agents), Ok(legacy_agents)) => { + merge_agent_catalogs(registry_agents, legacy_agents) + } + (Ok(registry_agents), Err(legacy_error)) => { + log::warn!("{}", legacy_error); + registry_agents + } + (Err(registry_error), Ok(legacy_agents)) => { + log::warn!("{}", registry_error); + legacy_agents + } + (Err(registry_error), Err(legacy_error)) => { + return Err(format!( + "{}; legacy agent catalog also failed: {}", + registry_error, legacy_error + )); + } + }; + + let mut cached = cache + .lock() + .map_err(|_| "Agent catalog cache poisoned".to_string())?; + *cached = Some(CachedAgentCatalog { + loaded_at: Instant::now(), + agents: agents.clone(), + }); + + let agent_settings = load_agent_server_settings(app_handle).map_err(|error| { + log::warn!("{}", error); + error + })?; + Ok(apply_agent_server_settings(agents, agent_settings)) +} + +async fn load_legacy_marketplace_agents() -> Result, String> { let response = reqwest::Client::new() .get(extensions_manifest_url()) .timeout(Duration::from_secs(5)) @@ -256,19 +833,11 @@ async fn load_marketplace_agents() -> Result, String> { .collect::>(); agents.sort_by_key(|agent| agent.name.clone()); - let mut cached = cache - .lock() - .map_err(|_| "Agent catalog cache poisoned".to_string())?; - *cached = Some(CachedAgentCatalog { - loaded_at: Instant::now(), - agents: agents.clone(), - }); - Ok(agents) } -async fn refresh_registered_agents(bridge: &mut AcpAgentBridge) { - match load_marketplace_agents().await { +async fn refresh_registered_agents(app_handle: &AppHandle, bridge: &mut AcpAgentBridge) { + match load_marketplace_agents(app_handle).await { Ok(agents) => bridge.replace_registered_agents(agents), Err(error) => { log::warn!("{}", error); @@ -401,7 +970,7 @@ fn tool_config_from_agent(agent: &AgentConfig) -> Result { command: agent.install_command.clone(), runtime, package, - packages: vec![], + packages: acp_python_companion_packages(&agent.id), download_url: agent.install_download_url.clone(), args: vec![], env: HashMap::new(), @@ -422,17 +991,27 @@ fn remove_managed_tool(app_handle: &AppHandle, tool_config: &ToolConfig) -> Resu }; let tools_dir = ToolInstaller::get_tools_dir(app_handle).map_err(|e| e.to_string())?; let path = match tool_config.runtime { - ToolRuntime::Node => tools_dir.join("npm").join(package), - ToolRuntime::Python => tools_dir.join("python").join(package), + ToolRuntime::Node => tools_dir + .join("npm") + .join(ToolInstaller::managed_dir_name(package)), + ToolRuntime::Python => tools_dir + .join("python") + .join(ToolInstaller::managed_dir_name(package)), ToolRuntime::Go => { ToolInstaller::get_tool_path(app_handle, tool_config).map_err(|e| e.to_string())? } ToolRuntime::Rust => { ToolInstaller::get_tool_path(app_handle, tool_config).map_err(|e| e.to_string())? } - ToolRuntime::Binary => tools_dir.join("binary").join(&tool_config.name), - ToolRuntime::Bun => tools_dir.join("bun").join(package), - ToolRuntime::Ruby => tools_dir.join("ruby").join(package), + ToolRuntime::Binary => tools_dir + .join("binary") + .join(ToolInstaller::managed_dir_name(&tool_config.name)), + ToolRuntime::Bun => tools_dir + .join("bun") + .join(ToolInstaller::managed_dir_name(package)), + ToolRuntime::Ruby => tools_dir + .join("ruby") + .join(ToolInstaller::managed_dir_name(package)), }; if path.is_dir() { @@ -469,11 +1048,16 @@ async fn write_acp_wrapper( .map_err(|e| e.to_string())?; build_node_wrapper(&node_path, &entrypoint) } + Some(AgentRuntime::Binary) if agent.id == "sigit" => build_stdout_filtered_wrapper( + installed_binary, + "exec \"$binary\" \"$@\" | awk '/^[[:space:]]*[\\{\\[]/ { print; fflush(); }'", + ), _ => build_binary_wrapper(installed_binary), }; std::fs::write(&wrapper_path, wrapper_contents).map_err(|e| e.to_string())?; make_wrapper_executable(&wrapper_path)?; + write_acp_install_receipt(app_handle, agent)?; Ok(()) } @@ -482,14 +1066,102 @@ fn acp_wrapper_path(app_handle: &AppHandle, agent_id: &str) -> Result Result { + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {}", e))?; + Ok(data_dir + .join("tools") + .join("acp") + .join(".receipts") + .join(format!("{}.json", receipt_file_stem(agent_id)))) +} + +fn receipt_file_stem(agent_id: &str) -> String { + let stem = agent_id + .chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => character, + _ => '_', + }) + .collect::(); + if stem == "." || stem == ".." { + stem.replace('.', "_") + } else { + stem + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AcpInstallReceipt { + agent_id: String, + install_runtime: Option, + install_package: Option, + install_download_url: Option, + install_command: Option, + wrapper_version: Option, + installed_at_unix_seconds: u64, +} + +fn write_acp_install_receipt(app_handle: &AppHandle, agent: &AgentConfig) -> Result<(), String> { + let receipt_path = acp_install_receipt_path(app_handle, &agent.id)?; + if let Some(parent) = receipt_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create ACP receipt dir: {}", e))?; + } + + let receipt = AcpInstallReceipt { + agent_id: agent.id.clone(), + install_runtime: agent_runtime_key(agent), + install_package: agent.install_package.clone(), + install_download_url: agent.install_download_url.clone(), + install_command: agent.install_command.clone(), + wrapper_version: acp_wrapper_version(agent), + installed_at_unix_seconds: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(), + }; + let json = serde_json::to_string_pretty(&receipt) + .map_err(|e| format!("Failed to encode ACP install receipt: {}", e))?; + fs::write(receipt_path, json).map_err(|e| format!("Failed to write ACP install receipt: {}", e)) +} + +fn remove_acp_install_receipt(app_handle: &AppHandle, agent_id: &str) -> Result<(), String> { + let receipt_path = acp_install_receipt_path(app_handle, agent_id)?; + if receipt_path.exists() { + fs::remove_file(&receipt_path) + .map_err(|e| format!("Failed to remove ACP install receipt: {}", e))?; + } + Ok(()) +} + +fn agent_runtime_key(agent: &AgentConfig) -> Option { + agent + .install_runtime + .as_ref() + .and_then(|runtime| serde_json::to_value(runtime).ok()) + .and_then(|value| value.as_str().map(ToString::to_string)) +} + +fn acp_wrapper_version(agent: &AgentConfig) -> Option { + if agent.id == "sigit" { + Some(SIGIT_WRAPPER_VERSION) + } else { + None + } +} + fn build_binary_wrapper(binary: &Path) -> String { #[cfg(target_os = "windows")] { @@ -502,6 +1174,18 @@ fn build_binary_wrapper(binary: &Path) -> String { } } +fn build_stdout_filtered_wrapper(binary: &Path, command: &str) -> String { + #[cfg(target_os = "windows")] + { + format!("@echo off\r\n\"{}\" %*\r\n", binary.display()) + } + + #[cfg(not(target_os = "windows"))] + { + format!("#!/bin/sh\nbinary='{}'\n{}\n", binary.display(), command) + } +} + fn build_node_wrapper(node_path: &Path, entrypoint: &Path) -> String { #[cfg(target_os = "windows")] { @@ -534,3 +1218,353 @@ fn make_wrapper_executable(path: &PathBuf) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(id: &str, name: &str, binary_name: &str) -> AgentConfig { + AgentConfig { + id: id.to_string(), + name: name.to_string(), + binary_name: binary_name.to_string(), + binary_path: None, + args: Vec::new(), + env_vars: HashMap::new(), + default_mode: None, + default_model: None, + icon: None, + description: None, + installed: false, + install_runtime: None, + install_package: None, + install_download_url: None, + install_command: None, + can_install: false, + update_available: false, + } + } + + #[test] + fn merge_agent_catalogs_prefers_registry_and_preserves_legacy_only_agents() { + let mut registry = agent("codex-acp", "Codex Registry", "npx"); + registry.args = vec!["-y".to_string(), "@vendor/codex-acp".to_string()]; + let legacy = agent("codex-acp", "Codex Legacy", "codex-acp"); + let legacy_only = agent("athas-local", "Athas Local", "athas-local"); + + let merged = merge_agent_catalogs(vec![registry], vec![legacy, legacy_only]); + + assert_eq!(merged.len(), 2); + let codex = merged + .iter() + .find(|candidate| candidate.id == "codex-acp") + .expect("codex agent"); + assert_eq!(codex.name, "Codex Registry"); + assert_eq!(codex.binary_name, "npx"); + assert!(merged.iter().any(|candidate| candidate.id == "athas-local")); + } + + #[test] + fn merge_agent_catalogs_deduplicates_renamed_legacy_agents_by_name() { + let registry = agent("codex-acp", "Codex CLI", "codex-acp"); + let renamed_legacy = agent("codex-cli", "Codex CLI", "codex"); + let legacy_only = agent("claude-code", "Claude Code", "claude"); + + let merged = merge_agent_catalogs(vec![registry], vec![renamed_legacy, legacy_only]); + + assert_eq!(merged.len(), 2); + assert!(merged.iter().any(|candidate| candidate.id == "codex-acp")); + assert!(!merged.iter().any(|candidate| candidate.id == "codex-cli")); + assert!(merged.iter().any(|candidate| candidate.id == "claude-code")); + } + + #[test] + fn registry_settings_override_env_and_defaults_without_dropping_agent() { + let mut base = agent("codex-acp", "Codex", "npx"); + base.env_vars.insert( + "BASE_URL".to_string(), + "https://registry.example".to_string(), + ); + base + .env_vars + .insert("KEEP_ME".to_string(), "registry".to_string()); + + let mut env = HashMap::new(); + env.insert("BASE_URL".to_string(), "https://user.example".to_string()); + env.insert("USER_ONLY".to_string(), "true".to_string()); + let mut settings = HashMap::new(); + settings.insert( + "codex-acp".to_string(), + AcpAgentServerSetting::Registry { + env, + default_mode: Some("plan".to_string()), + default_model: Some("gpt-5.5".to_string()), + }, + ); + + let agents = apply_agent_server_settings(vec![base], settings); + let codex = agents.first().expect("codex agent"); + + assert_eq!( + codex.env_vars.get("BASE_URL").map(String::as_str), + Some("https://user.example") + ); + assert_eq!( + codex.env_vars.get("KEEP_ME").map(String::as_str), + Some("registry") + ); + assert_eq!( + codex.env_vars.get("USER_ONLY").map(String::as_str), + Some("true") + ); + assert_eq!(codex.default_mode.as_deref(), Some("plan")); + assert_eq!(codex.default_model.as_deref(), Some("gpt-5.5")); + } + + #[test] + fn custom_agent_settings_create_runnable_agent_config() { + let mut env = HashMap::new(); + env.insert("CUSTOM_TOKEN".to_string(), "secret".to_string()); + let mut settings = HashMap::new(); + settings.insert( + "my-agent".to_string(), + AcpAgentServerSetting::Custom { + command: "/usr/local/bin/my-agent".to_string(), + args: vec!["--acp".to_string()], + env, + default_mode: Some(" act ".to_string()), + default_model: Some(" custom-model ".to_string()), + }, + ); + + let agents = apply_agent_server_settings(Vec::new(), settings); + let custom = agents.first().expect("custom agent"); + + assert_eq!(custom.id, "my-agent"); + assert_eq!(custom.name, "my-agent"); + assert_eq!(custom.binary_name, "my-agent"); + assert_eq!( + custom.binary_path.as_deref(), + Some("/usr/local/bin/my-agent") + ); + assert_eq!(custom.args, vec!["--acp"]); + assert_eq!( + custom.env_vars.get("CUSTOM_TOKEN").map(String::as_str), + Some("secret") + ); + assert_eq!(custom.default_mode.as_deref(), Some("act")); + assert_eq!(custom.default_model.as_deref(), Some("custom-model")); + assert!(!custom.can_install); + } + + #[test] + fn malformed_custom_agent_settings_are_skipped() { + let mut settings = HashMap::new(); + settings.insert( + "broken".to_string(), + AcpAgentServerSetting::Custom { + command: " ".to_string(), + args: Vec::new(), + env: HashMap::new(), + default_mode: None, + default_model: None, + }, + ); + + let agents = apply_agent_server_settings(Vec::new(), settings); + + assert!(agents.is_empty()); + } + + #[test] + fn npm_package_name_strips_registry_version_specs() { + assert_eq!(npm_package_name("cline@2.18.0"), "cline"); + assert_eq!( + npm_package_name("@agentclientprotocol/claude-agent-acp@0.33.1"), + "@agentclientprotocol/claude-agent-acp" + ); + assert_eq!(npm_package_name("@scope/package"), "@scope/package"); + } + + #[test] + fn npx_command_name_uses_known_package_binary_aliases() { + assert_eq!(npx_command_name("@google/gemini-cli", "gemini"), "gemini"); + assert_eq!( + npx_command_name("@qwen-code/qwen-code", "qwen-code"), + "qwen" + ); + assert_eq!( + npx_command_name("@tencent-ai/codebuddy-code", "codebuddy-code"), + "codebuddy" + ); + assert_eq!(npx_command_name("dirac-cli", "dirac"), "dirac"); + assert_eq!(npx_command_name("cline", "cline"), "cline"); + } + + #[test] + fn python_package_spec_from_uvx_converts_registry_version_specs() { + assert_eq!( + python_package_spec_from_uvx("fast-agent-acp==0.7.1"), + "fast-agent-mcp==0.7.1" + ); + assert_eq!( + python_package_spec_from_uvx("minion-code@0.1.44"), + "minion-code==0.1.44" + ); + } + + #[test] + fn python_command_name_uses_fast_agent_acp_entrypoint() { + assert_eq!( + python_command_name("fast-agent-mcp==0.7.1", "fast-agent"), + "fast-agent-acp" + ); + assert_eq!( + python_command_name("minion-code==0.1.44", "minion-code"), + "minion-code" + ); + } + + #[test] + fn qwen_registry_args_use_current_acp_flag() { + assert_eq!( + registry_agent_args( + "qwen-code", + vec![ + "--acp".to_string(), + "--experimental-skills".to_string(), + "--other".to_string() + ] + ), + vec!["--experimental-acp".to_string(), "--other".to_string()] + ); + assert_eq!( + registry_agent_args("qwen-code", vec!["acp".to_string()]), + vec!["--experimental-acp".to_string()] + ); + assert_eq!( + registry_agent_args("qwen-code", Vec::new()), + vec!["--experimental-acp".to_string()] + ); + } + + #[test] + fn minion_install_config_pins_compatible_acp_package() { + let mut minion = agent("minion-code", "Minion Code", "minion-code"); + minion.install_runtime = Some(AgentRuntime::Python); + minion.install_package = Some("minion-code==0.1.44".to_string()); + + let config = tool_config_from_agent(&minion).expect("tool config"); + + assert_eq!(config.packages, vec!["agent-client-protocol<0.9"]); + } + + #[test] + fn acp_registry_json_maps_npx_distribution_as_managed_node_install() { + let json = r#"{ + "agents": [ + { + "id": "qwen-code", + "name": "Qwen Code", + "description": "Qwen ACP adapter", + "icon": "codex.svg", + "distribution": { + "npx": { + "package": "@qwen-code/qwen-code@0.15.9", + "args": ["--acp"], + "env": { "REGISTRY_ENV": "1" } + } + } + } + ] + }"#; + + let agents = acp_registry_agents_from_json(json).expect("registry agents"); + let qwen = agents.first().expect("qwen agent"); + + assert_eq!(qwen.id, "qwen-code"); + assert_eq!(qwen.binary_name, "qwen"); + assert_eq!(qwen.args, vec!["--experimental-acp".to_string()]); + assert_eq!(qwen.install_runtime, Some(AgentRuntime::Node)); + assert_eq!( + qwen.install_package.as_deref(), + Some("@qwen-code/qwen-code@0.15.9") + ); + assert_eq!(qwen.install_command.as_deref(), Some("qwen")); + assert!(qwen.can_install); + assert_eq!( + qwen.env_vars.get("REGISTRY_ENV").map(String::as_str), + Some("1") + ); + } + + #[test] + fn acp_registry_json_skips_excluded_agents() { + let json = r#"{ + "agents": [ + { + "id": "agoragentic-acp", + "name": "Agoragentic", + "description": "Marketplace adapter", + "distribution": { + "npx": { + "package": "agoragentic-mcp@1.3.0", + "args": ["--acp"] + } + } + }, + { + "id": "codex-acp", + "name": "Codex", + "description": "Codex ACP adapter", + "distribution": { + "npx": { + "package": "@vendor/codex-acp" + } + } + } + ] + }"#; + + let agents = acp_registry_agents_from_json(json).expect("registry agents"); + + assert_eq!(agents.len(), 1); + assert_eq!( + agents.first().map(|agent| agent.id.as_str()), + Some("codex-acp") + ); + } + + #[test] + fn acp_registry_json_maps_uvx_distribution_as_managed_python_install() { + let json = r#"{ + "agents": [ + { + "id": "minion-code", + "name": "Minion Code", + "description": "Minion ACP adapter", + "distribution": { + "uvx": { + "package": "minion-code@0.1.44", + "args": ["acp"] + } + } + } + ] + }"#; + + let agents = acp_registry_agents_from_json(json).expect("registry agents"); + let minion = agents.first().expect("minion agent"); + + assert_eq!(minion.id, "minion-code"); + assert_eq!(minion.binary_name, "minion-code"); + assert_eq!(minion.args, vec!["acp".to_string()]); + assert_eq!(minion.install_runtime, Some(AgentRuntime::Python)); + assert_eq!( + minion.install_package.as_deref(), + Some("minion-code==0.1.44") + ); + assert_eq!(minion.install_command.as_deref(), Some("minion-code")); + assert!(minion.can_install); + } +} diff --git a/src/features/ai/components/agent-tab.tsx b/src/features/ai/components/agent-tab.tsx index aa91c28dd..f1fc52438 100644 --- a/src/features/ai/components/agent-tab.tsx +++ b/src/features/ai/components/agent-tab.tsx @@ -12,21 +12,39 @@ interface AgentTabProps { export function AgentTab({ buffer, isActive = true }: AgentTabProps) { const buffers = useBufferStore.use.buffers(); const updateBuffer = useBufferStore.use.actions().updateBuffer; + const activeBuffer = buffers.find((b) => b.id === buffer.id) ?? (buffer as PaneContent); + const activeAgentBuffer = activeBuffer.type === "agent" ? activeBuffer : buffer; + const activeSessionId = activeAgentBuffer.sessionId; + const createNewChat = useAIChatStore((state) => state.createNewChat); + const selectedAgentId = useAIChatStore((state) => state.selectedAgentId); + const hasChat = useAIChatStore((state) => + state.chats.some((chat) => chat.id === activeSessionId), + ); const chatTitle = useAIChatStore( - (state) => state.chats.find((chat) => chat.id === buffer.sessionId)?.title, + (state) => state.chats.find((chat) => chat.id === activeSessionId)?.title, ); - const activeBuffer = buffers.find((b) => b.id === buffer.id) ?? (buffer as PaneContent); useEffect(() => { - if (!chatTitle || chatTitle === buffer.name) return; - updateBuffer({ ...buffer, name: chatTitle }); - }, [buffer, chatTitle, updateBuffer]); + if (hasChat) return; + + const chatId = createNewChat(selectedAgentId); + updateBuffer({ + ...activeAgentBuffer, + path: `agent://${chatId}`, + sessionId: chatId, + }); + }, [activeAgentBuffer, createNewChat, hasChat, selectedAgentId, updateBuffer]); + + useEffect(() => { + if (!chatTitle || chatTitle === activeAgentBuffer.name) return; + updateBuffer({ ...activeAgentBuffer, name: chatTitle }); + }, [activeAgentBuffer, chatTitle, updateBuffer]); return (
(null); const messagesContainerRef = useRef(null); @@ -218,8 +252,13 @@ const AIChat = memo(function AIChat({ }> >([]); const [acpEvents, setAcpEvents] = useState([]); - const effectiveChatId = - chatId ?? (activeBuffer?.type === "agent" ? activeBuffer.sessionId : chatState.currentChatId); + const activeAgentChatId = useMemo(() => { + if (activeBuffer?.type !== "agent") return null; + return chatState.chats.some((chat) => chat.id === activeBuffer.sessionId) + ? activeBuffer.sessionId + : null; + }, [activeBuffer, chatState.chats]); + const effectiveChatId = chatId ?? activeAgentChatId ?? chatState.currentChatId; useEffect(() => { if (isActiveSurface && activeBuffer) { @@ -234,6 +273,20 @@ const AIChat = memo(function AIChat({ chatActions.switchToChat(effectiveChatId); }, [chatActions, chatState.chats, chatState.currentChatId, effectiveChatId, isActiveSurface]); + const handleAgentChatCreated = useCallback( + (chatId: string) => { + if (activeBuffer?.type !== "agent") return; + + updateBuffer({ + ...activeBuffer, + path: `agent://${chatId}`, + sessionId: chatId, + name: "New Chat", + }); + }, + [activeBuffer, updateBuffer], + ); + useEffect(() => { chatActions.checkApiKey(settings.aiProviderId); chatActions.checkAllProviderApiKeys(); @@ -495,6 +548,13 @@ const AIChat = memo(function AIChat({ if (!chatId) { chatId = chatActions.createNewChat(currentAgentId); } + if (activeBuffer?.type === "agent" && activeBuffer.sessionId !== chatId) { + useBufferStore.getState().actions.updateBuffer({ + ...activeBuffer, + path: `agent://${chatId}`, + sessionId: chatId, + }); + } const { processedMessage, mentionedFiles } = await parseMentionsAndLoadFiles( messageContent.trim(), @@ -590,8 +650,9 @@ const AIChat = memo(function AIChat({ enhancedMessage, context, (chunk: string) => { + const nextChunk = isAcp ? (formatStructuredTransportError(chunk) ?? chunk) : chunk; updateStreamingAssistantMessage(chatId, currentAssistantMessageId, (currentMessage) => ({ - content: (currentMessage?.content || "") + chunk, + content: (currentMessage?.content || "") + nextChunk, })); requestAnimationFrame(() => scrollToBottom()); }, @@ -665,6 +726,12 @@ details: The agent session started, but no content, tool output, or resource was errorDetails = parts[1]; } + const structuredErrorMessage = parseStructuredErrorMessage(errorDetails || mainError); + if (structuredErrorMessage) { + errorMessage = structuredErrorMessage; + errorDetails = structuredErrorMessage; + } + const codeMatch = mainError.match(/error:\s*(\d+)/i); if (codeMatch) { errorCode = codeMatch[1]; @@ -678,6 +745,11 @@ details: The agent session started, but no content, tool output, or resource was } else if (errorCode === "403") { errorTitle = "Access Denied"; errorMessage = "You don't have permission to access this resource."; + } else if (errorCode === "402") { + errorTitle = "Payment Required"; + if (!structuredErrorMessage) { + errorMessage = "The selected agent requires paid credits before it can run."; + } } else if (errorCode === "500") { errorTitle = "Server Error"; errorMessage = "The API server encountered an error. Please try again later."; @@ -696,27 +768,15 @@ details: The agent session started, but no content, tool output, or resource was } } - const isAcpAuthError = - isAcpAgent(currentAgentId) && - (mainError.includes("Authentication required") || - errorDetails.includes("Authentication required")); - - if (isAcpAuthError) { - errorTitle = "Authentication Required"; - errorCode = "AUTH_REQUIRED"; - errorMessage = - "The selected agent needs external authentication before it can accept prompts."; - - if ( - mainError.includes("Method not implemented") || - errorDetails.includes("Method not implemented") - ) { - errorDetails = - "This ACP adapter does not implement the protocol authenticate flow. Complete login in the underlying CLI/adapter, then try again."; - } else if (!errorDetails) { - errorDetails = - "Complete authentication in the underlying CLI/adapter, then try again."; - } + const acpProviderError = isAcpAgent(currentAgentId) + ? classifyAcpProviderError(mainError, errorDetails) + : null; + + if (acpProviderError) { + errorTitle = acpProviderError.title; + errorCode = acpProviderError.code; + errorMessage = acpProviderError.message; + errorDetails = acpProviderError.detail; } if (canReconnect) { @@ -958,10 +1018,11 @@ details: ${errorDetails || mainError} useAIChatStore.getState().setAcpStatus(event.status); break; // internal state sync case "error": + const acpProviderError = classifyAcpProviderError(event.error, "", event.errorKind); appendAcpEvent({ kind: "error", - label: "Agent error", - detail: truncateDetail(event.error), + label: acpProviderError?.activityLabel ?? "Agent error", + detail: truncateDetail(acpProviderError?.detail ?? event.error), state: "error", }); break; @@ -1097,7 +1158,11 @@ details: ${errorDetails || mainError}
- + {isAiChatBlockedByPolicy ? (
diff --git a/src/features/ai/components/chat/chat-header.tsx b/src/features/ai/components/chat/chat-header.tsx index ec6d1ddf2..05cbde071 100644 --- a/src/features/ai/components/chat/chat-header.tsx +++ b/src/features/ai/components/chat/chat-header.tsx @@ -85,9 +85,10 @@ function EditableChatTitle({ interface ChatHeaderProps { chatId?: string | null; onDeleteChat?: (chatId: string, event: React.MouseEvent) => void; + onAgentChatCreated?: (chatId: string) => void; } -export function ChatHeader({ chatId, onDeleteChat }: ChatHeaderProps) { +export function ChatHeader({ chatId, onDeleteChat, onAgentChatCreated }: ChatHeaderProps) { const currentChatId = useAIChatStore((state) => state.currentChatId); const chats = useAIChatStore((state) => state.chats); const selectedAgentId = useAIChatStore((state) => state.selectedAgentId); @@ -134,7 +135,11 @@ export function ChatHeader({ chatId, onDeleteChat }: ChatHeaderProps) { - openSettingsDialog("ai")} /> + openSettingsDialog("ai")} + />
void; } -export const ChatMessage = memo(function ChatMessage({ message, onApplyCode }: ChatMessageProps) { +export const ChatMessage = memo(function ChatMessage({ + message, + agentIconId, + onApplyCode, +}: ChatMessageProps) { const isToolOnlyMessage = message.role === "assistant" && message.toolCalls && @@ -44,21 +50,23 @@ export const ChatMessage = memo(function ChatMessage({ message, onApplyCode }: C if (isToolOnlyMessage) { return ( -
- {message.toolCalls!.map((toolCall, toolIndex) => ( - - ))} -
+ +
+ {message.toolCalls!.map((toolCall, toolIndex) => ( + + ))} +
+
); } @@ -68,11 +76,15 @@ export const ChatMessage = memo(function ChatMessage({ message, onApplyCode }: C (!message.content || message.content.trim().length === 0) && (!message.toolCalls || message.toolCalls.length === 0) ) { - return ; + return ( + + + + ); } return ( -
+ {message.images && message.images.length > 0 && (
{message.images.map((image, index) => ( @@ -143,6 +155,23 @@ export const ChatMessage = memo(function ChatMessage({ message, onApplyCode }: C ))}
)} -
+ ); }); + +function AssistantMessageFrame({ + agentIconId, + children, +}: { + agentIconId?: string; + children: React.ReactNode; +}) { + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/src/features/ai/components/chat/chat-messages.tsx b/src/features/ai/components/chat/chat-messages.tsx index 1f7de879d..85db44900 100644 --- a/src/features/ai/components/chat/chat-messages.tsx +++ b/src/features/ai/components/chat/chat-messages.tsx @@ -36,6 +36,7 @@ export const ChatMessages = memo( })), ); const skills = useSettingsStore((state) => state.settings.aiSkills); + const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); const [isSkillsOpen, setIsSkillsOpen] = useState(false); const [skillsInitialView, setSkillsInitialView] = useState<"list" | "editor">("list"); @@ -44,6 +45,7 @@ export const ChatMessages = memo( [chatId, chats, currentChatId], ); const messages = currentChat?.messages || []; + const agentIconId = currentChat?.agentId === "custom" ? aiProviderId : currentChat?.agentId; const timelineItems = useMemo( () => [ @@ -164,6 +166,7 @@ export const ChatMessages = memo(
diff --git a/src/features/ai/components/input/chat-input-bar.tsx b/src/features/ai/components/input/chat-input-bar.tsx index a7e95bff6..f8f801a29 100644 --- a/src/features/ai/components/input/chat-input-bar.tsx +++ b/src/features/ai/components/input/chat-input-bar.tsx @@ -1352,7 +1352,7 @@ const AIChatInputBar = memo(function AIChatInputBar({ {queueCount > 0 && ( {queueCount} diff --git a/src/features/ai/components/messages/markdown-renderer.tsx b/src/features/ai/components/messages/markdown-renderer.tsx index 745186f48..64ccafcf5 100644 --- a/src/features/ai/components/messages/markdown-renderer.tsx +++ b/src/features/ai/components/messages/markdown-renderer.tsx @@ -8,6 +8,10 @@ import { import type React from "react"; import { useEffect, useMemo, useState } from "react"; import type { MarkdownRendererProps } from "@/features/ai/types/ai-chat"; +import { + extractProviderSetupCommand, + parseErrorBlockData, +} from "@/features/ai/lib/error-block-data"; import { useAIChatStore } from "@/features/ai/store/store"; import { useBufferStore } from "@/features/editor/stores/buffer-store"; import { Button } from "@/ui/button"; @@ -381,33 +385,18 @@ function ErrorBlock({ errorData }: { errorData: string }) { const setSessionModeState = useAIChatStore((state) => state.setSessionModeState); const setSessionConfigOptions = useAIChatStore((state) => state.setSessionConfigOptions); - const lines = errorData.split("\n"); - const title = - lines - .find((l) => l.startsWith("title:")) - ?.replace("title:", "") - .trim() || ""; - const code = - lines - .find((l) => l.startsWith("code:")) - ?.replace("code:", "") - .trim() || ""; - const message = - lines - .find((l) => l.startsWith("message:")) - ?.replace("message:", "") - .trim() || ""; - const details = - lines - .find((l) => l.startsWith("details:")) - ?.replace("details:", "") - .trim() || ""; + const { title, code, message, details } = parseErrorBlockData(errorData); const summary = title || message || "Error"; const normalizedDetails = details && details !== message ? details : ""; - const isAuthRequired = code === "AUTH_REQUIRED"; + const needsProviderSetup = code === "AUTH_REQUIRED" || code === "PROVIDER_SETUP_REQUIRED"; const suggestedCommand = useMemo(() => { const normalizedText = `${summary} ${message} ${normalizedDetails}`.toLowerCase(); + const setupCommand = extractProviderSetupCommand(normalizedDetails); + + if (setupCommand) { + return setupCommand; + } if (normalizedText.includes("claude code")) { return "claude auth login"; @@ -466,7 +455,7 @@ function ErrorBlock({ errorData }: { errorData: string }) { )}
- {isAuthRequired && ( + {needsProviderSetup && (
)} diff --git a/src/features/ai/components/selectors/agent-selector.tsx b/src/features/ai/components/selectors/agent-selector.tsx index d1592b926..30f1967fc 100644 --- a/src/features/ai/components/selectors/agent-selector.tsx +++ b/src/features/ai/components/selectors/agent-selector.tsx @@ -6,7 +6,14 @@ import { SlidersHorizontal as Settings2, SpinnerGap, } from "@phosphor-icons/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type WheelEvent as ReactWheelEvent, +} from "react"; import { ProviderIcon } from "@/features/ai/components/icons/provider-icons"; import { AcpStreamHandler } from "@/features/ai/services/acp-stream-handler"; import { useAIChatStore } from "@/features/ai/store/store"; @@ -23,14 +30,60 @@ const ATHAS_AGENT_OPTION = { id: "custom", name: "Athas Agent", description: "Use Athas chat settings and provider configuration", + icon: null, isAcp: false, }; +function AgentIcon({ + agentId, + icon, + size, + className, +}: { + agentId: string; + icon?: string | null; + size: number; + className?: string; +}) { + const [didFail, setDidFail] = useState(false); + + if (icon && !didFail) { + const hue = agentIconHue(agentId); + + return ( + setDidFail(true)} + className={cn("shrink-0 object-contain", className)} + style={{ + filter: `brightness(0) saturate(100%) invert(82%) sepia(82%) saturate(1180%) hue-rotate(${hue}deg) brightness(101%) contrast(96%)`, + }} + /> + ); + } + + return ; +} + +function agentIconHue(agentId: string) { + let hash = 0; + for (let index = 0; index < agentId.length; index++) { + hash = (hash * 31 + agentId.charCodeAt(index)) >>> 0; + } + return hash % 360; +} + interface AgentSelectorProps { variant?: "header" | "input"; onOpenSettings?: () => void; selectedAgentId?: AgentType; onSelectAgent?: (agentId: AgentType) => void; + onAgentChatCreated?: (chatId: string) => void; portalContainer?: Element | DocumentFragment | null; triggerClassName?: string; triggerTooltip?: string; @@ -42,6 +95,7 @@ export function AgentSelector({ onOpenSettings, selectedAgentId, onSelectAgent, + onAgentChatCreated, portalContainer, triggerClassName, triggerTooltip, @@ -60,6 +114,8 @@ export function AgentSelector({ const triggerRef = useRef(null); const inputRef = useRef(null); + const listRef = useRef(null); + const itemRefs = useRef>([]); const previousOpenSignalRef = useRef(openSignal); const currentAgentId = selectedAgentId ?? getCurrentAgentId(); @@ -93,6 +149,7 @@ export function AgentSelector({ id: string; name: string; description: string; + icon?: string | null; isInstalled?: boolean; isCurrent?: boolean; canInstall?: boolean; @@ -120,6 +177,7 @@ export function AgentSelector({ id: agent.id, name: agent.name, description: agentConfig?.description ?? agent.description ?? "ACP-compatible coding agent", + icon: agentConfig?.icon ?? agent.icon, isInstalled, isCurrent: agent.id === currentAgentId, canInstall: agent.id === "custom" ? false : (agentConfig?.canInstall ?? true), @@ -149,6 +207,11 @@ export function AgentSelector({ setSelectedIndex(0); }, [search]); + useEffect(() => { + if (!isOpen) return; + itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, [isOpen, selectedIndex]); + useEffect(() => { if (!isOpen) { setSearch(""); @@ -156,6 +219,20 @@ export function AgentSelector({ } }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, [isOpen, selectedIndex]); + + const handleAgentListWheel = useCallback((event: ReactWheelEvent) => { + const list = event.currentTarget; + if (list.scrollHeight <= list.clientHeight || event.deltaY === 0) return; + + event.preventDefault(); + event.stopPropagation(); + list.scrollTop += event.deltaY; + }, []); + const handleAgentChange = useCallback( async (agentId: AgentType) => { if (onSelectAgent) { @@ -182,6 +259,7 @@ export function AgentSelector({ if (variant === "header") { const newChatId = createNewChat(agentId); + onAgentChatCreated?.(newChatId); if (agentId !== "custom") { void AcpStreamHandler.warmup(agentId, newChatId).catch((error) => { console.error(`Failed to prepare ${agentId} session:`, error); @@ -198,6 +276,7 @@ export function AgentSelector({ setSelectedAgentId, changeCurrentChatAgent, createNewChat, + onAgentChatCreated, ], ); @@ -206,6 +285,14 @@ export function AgentSelector({ if (agentId === "custom" || installingAgentId) return; setInstallingAgentId(agentId); + const toastKey = `acp-agent-install:${agentId}`; + toast.show({ + key: toastKey, + message: `Installing ${agentName}...`, + description: "Downloading and preparing the ACP agent.", + type: "info", + duration: 10000, + }); try { const installedAgent = await invoke("install_acp_agent", { agentId }); setAgentConfigs((current) => { @@ -214,12 +301,18 @@ export function AgentSelector({ return next; }); setInstalledAgents((current) => new Set(current).add(installedAgent.id)); - toast.success(`${agentName} installed`); + toast.show({ + key: toastKey, + message: `${agentName} installed`, + type: "success", + }); } catch (error) { - toast.error( - `Failed to install ${agentName}`, - error instanceof Error ? error.message : "Unknown error", - ); + toast.show({ + key: toastKey, + message: `Failed to install ${agentName}`, + description: error instanceof Error ? error.message : "Unknown error", + type: "error", + }); } finally { setInstallingAgentId(null); void loadInstalledAgents(); @@ -284,7 +377,12 @@ export function AgentSelector({ compact className="ui-font flex h-8 max-w-[min(220px,100%)] items-center gap-1.5 rounded-full border border-border bg-secondary-bg/80 px-3 text-xs transition-colors hover:bg-hover" > - + {currentAgent?.name || "Agent"} setIsOpen(false)} portalContainer={portalContainer} - className="flex w-[min(280px,calc(100vw-16px))] max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-xl p-0" - style={{ maxHeight: "240px" }} + className="flex w-[min(360px,calc(100vw-16px))] max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-xl p-0" + style={{ maxHeight: "min(620px, calc(100vh - 24px))" }} >
-
+
{filteredItems.length === 0 ? (
No results found
) : ( @@ -329,6 +431,9 @@ export function AgentSelector({ return (
{ + itemRefs.current[itemIndex] = element; + }} role="button" tabIndex={-1} onMouseEnter={() => setSelectedIndex(itemIndex)} @@ -342,21 +447,30 @@ export function AgentSelector({ } }} className={cn( - "group flex min-h-7 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors", + "group mb-1 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors last:mb-2", isSelected ? "bg-hover/90" : "bg-transparent", item.isCurrent && "bg-selected/90 ring-1 ring-accent/10", !item.isInstalled && item.id !== "custom" && "text-text-lighter", )} >
- +
-
+
{item.name}
{!item.isInstalled && item.id !== "custom" ? (
- {item.canInstall ? "Not installed" : item.description} + {item.isInstalling + ? "Installing..." + : item.canInstall + ? "Not installed" + : item.description}
) : null}
@@ -371,10 +485,20 @@ export function AgentSelector({ }} variant="ghost" compact - className="h-6 px-2 text-[10px]" + className="h-6 min-w-[4.75rem] px-2 text-[11px]" disabled={!item.canInstall || Boolean(installingAgentId)} + aria-label={ + item.isInstalling ? `Installing ${item.name}` : `Install ${item.name}` + } > - {item.isInstalling ? : "Install"} + {item.isInstalling ? ( + <> + + Installing + + ) : ( + "Install" + )} ) : null} {item.id === "custom" && onOpenSettings ? ( diff --git a/src/features/ai/lib/acp-provider-errors.ts b/src/features/ai/lib/acp-provider-errors.ts new file mode 100644 index 000000000..67d5313d7 --- /dev/null +++ b/src/features/ai/lib/acp-provider-errors.ts @@ -0,0 +1,60 @@ +export interface AcpProviderErrorClassification { + code: "AUTH_REQUIRED" | "PROVIDER_SETUP_REQUIRED"; + title: string; + message: string; + detail: string; + activityLabel: string; +} + +const SETUP_PATTERNS = [ + /\bno api key found\b/i, + /\bmissing api key\b/i, + /\bapi key.*required\b/i, + /\benvironment variable\b/i, + /\brun\s+[`"']?[\w.-]+(?:\s+[\w.:/@-]+)*\s+--setup[`"']?/i, + /\bnot logged in\b/i, + /\blogin required\b/i, + /\bauthentication required\b/i, +]; + +const AUTHENTICATE_NOT_IMPLEMENTED = /method not implemented/i; + +export function classifyAcpProviderError( + mainError: string, + errorDetails = "", + errorKind?: "authentication_required" | "provider_setup_required" | null, +): AcpProviderErrorClassification | null { + const text = [mainError, errorDetails].filter(Boolean).join("\n"); + + if (!text) return null; + + if (errorKind === "authentication_required" || text.includes("Authentication required")) { + const detail = AUTHENTICATE_NOT_IMPLEMENTED.test(text) + ? "This ACP adapter does not implement the protocol authenticate flow. Complete login in the underlying CLI/adapter, then try again." + : errorDetails || "Complete authentication in the underlying CLI/adapter, then try again."; + + return { + code: "AUTH_REQUIRED", + title: "Authentication Required", + message: "The selected agent needs external authentication before it can accept prompts.", + detail, + activityLabel: "Agent authentication required", + }; + } + + if ( + errorKind !== "provider_setup_required" && + !SETUP_PATTERNS.some((pattern) => pattern.test(text)) + ) { + return null; + } + + return { + code: "PROVIDER_SETUP_REQUIRED", + title: "Provider Setup Required", + message: + "Athas launched the selected ACP provider, but the provider needs setup before it can answer.", + detail: errorDetails || mainError, + activityLabel: "Provider setup required", + }; +} diff --git a/src/features/ai/lib/error-block-data.ts b/src/features/ai/lib/error-block-data.ts new file mode 100644 index 000000000..487861271 --- /dev/null +++ b/src/features/ai/lib/error-block-data.ts @@ -0,0 +1,36 @@ +export function parseErrorBlockData(errorData: string) { + const fields = new Map(); + let currentKey: string | null = null; + + for (const line of errorData.split("\n")) { + const fieldMatch = line.match(/^([a-z]+):\s?(.*)$/); + + if (fieldMatch) { + currentKey = fieldMatch[1]; + fields.set(currentKey, fieldMatch[2]); + continue; + } + + if (currentKey) { + const currentValue = fields.get(currentKey); + fields.set(currentKey, currentValue ? `${currentValue}\n${line}` : line); + } + } + + return { + title: fields.get("title")?.trim() ?? "", + code: fields.get("code")?.trim() ?? "", + message: fields.get("message")?.trim() ?? "", + details: fields.get("details")?.trim() ?? "", + }; +} + +export function extractProviderSetupCommand(value: string): string | null { + const explicitSetup = value.match(/\brun\s+`?([^`.\n]*?\b--setup(?:\s+[\w.:/@=-]+)*)`?/i); + if (explicitSetup?.[1]) { + return explicitSetup[1].trim(); + } + + const bareSetup = value.match(/`([\w.-]+(?:\s+[\w.:/@=-]+)*\s+--setup(?:\s+[\w.:/@=-]+)*)`/i); + return bareSetup?.[1]?.trim() ?? null; +} diff --git a/src/features/ai/services/acp-stream-handler.ts b/src/features/ai/services/acp-stream-handler.ts index 177e830c3..2dacc15c8 100644 --- a/src/features/ai/services/acp-stream-handler.ts +++ b/src/features/ai/services/acp-stream-handler.ts @@ -162,15 +162,22 @@ export class AcpStreamHandler { const message = error instanceof Error ? error.message : String(error); const normalized = message.toLowerCase(); + if ( + normalized.includes("auth") || + normalized.includes("credential") || + normalized.includes("api key") || + normalized.includes("login") || + normalized.includes("log in") || + normalized.includes("setup") + ) { + return `${this.agentId} requires authentication or setup before it can answer prompts.`; + } if (normalized.includes("runtime")) { return `${this.agentId} could not start because a required runtime is unavailable.`; } if (normalized.includes("install")) { return `${this.agentId} could not be installed automatically. Check network access and local tool permissions.`; } - if (normalized.includes("auth")) { - return `${this.agentId} requires authentication before it can answer prompts.`; - } return `${this.agentId} is currently unavailable.`; } @@ -311,6 +318,9 @@ export class AcpStreamHandler { case "session_info_update": break; + case "usage_update": + break; + case "prompt_complete": this.handlePromptComplete(event); break; @@ -327,6 +337,7 @@ export class AcpStreamHandler { // The stop reason can be used to determine how to handle the completion if (event.stopReason === "cancelled") { // User cancelled the prompt + this.finalizeActiveToolsAsCancelled(); this.cleanup(); this.handlers.onComplete(); return; @@ -542,10 +553,19 @@ export class AcpStreamHandler { } } + private finalizeActiveToolsAsCancelled(): void { + if (!this.handlers.onToolComplete || this.activeTools.size === 0) return; + + for (const [toolId, toolName] of this.activeTools) { + this.handlers.onToolComplete(toolName, toolId, undefined, "Cancelled"); + } + } + private forceStop(): void { if (this.sessionComplete || this.cancelled) return; this.cancelled = true; this.pendingNewMessage = false; + this.finalizeActiveToolsAsCancelled(); this.cleanup(); this.handlers.onComplete(); } diff --git a/src/features/ai/tests/acp-cancellation.test.ts b/src/features/ai/tests/acp-cancellation.test.ts new file mode 100644 index 000000000..50d700174 --- /dev/null +++ b/src/features/ai/tests/acp-cancellation.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { invoke } from "@tauri-apps/api/core"; +import { AcpStreamHandler } from "../services/acp-stream-handler"; +import type { AcpEvent } from "../types/acp"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +vi.mock("@/features/ai/store/store", () => ({ + useAIChatStore: { + getState: () => ({ + acpStatus: null, + getChatById: () => null, + getCurrentChat: () => null, + setAcpStatus: vi.fn(), + setChatAcpSessionId: vi.fn(), + setAvailableSlashCommands: vi.fn(), + setSessionConfigOptions: vi.fn(), + setSessionModeState: vi.fn(), + setCurrentModeId: vi.fn(), + }), + }, +})); + +vi.mock("@/features/editor/stores/buffer-store", () => ({ + useBufferStore: { + getState: () => ({ + actions: { + openWebViewerBuffer: vi.fn(), + openTerminalBuffer: vi.fn(), + }, + }), + }, +})); + +vi.mock("@/features/window/stores/project-store", () => ({ + useProjectStore: { + getState: () => ({ + rootFolderPath: "/repo", + }), + }, +})); + +vi.mock("@/features/ai/lib/acp-session-info", () => ({ + getChatTitleFromSessionInfo: (_currentTitle: string | undefined, nextTitle: string) => nextTitle, +})); + +vi.mock("../utils/ai-context-builder", () => ({ + buildContextPrompt: () => "", +})); + +const mockedInvoke = vi.mocked(invoke); + +type TestableAcpStreamHandler = { + handleAcpEvent: (event: unknown) => void; +}; + +type AcpStreamHandlerStatic = { + activeHandler: AcpStreamHandler | null; +}; + +const setActiveHandler = (handler: AcpStreamHandler | null) => { + (AcpStreamHandler as unknown as AcpStreamHandlerStatic).activeHandler = handler; +}; + +const handleAcpEvent = (handler: AcpStreamHandler, event: AcpEvent) => { + (handler as unknown as TestableAcpStreamHandler).handleAcpEvent(event); +}; + +describe("AcpStreamHandler cancellation", () => { + afterEach(() => { + mockedInvoke.mockReset(); + setActiveHandler(null); + }); + + it("finalizes active tools before sending backend cancellation", async () => { + const onComplete = vi.fn(); + const onToolComplete = vi.fn(); + const handler = new AcpStreamHandler( + "codex", + { + onChunk: vi.fn(), + onComplete, + onError: vi.fn(), + onToolComplete, + }, + "chat-1", + ); + + handleAcpEvent(handler, { + type: "tool_start", + sessionId: "session-1", + toolName: "read_text_file", + toolId: "tool-1", + input: { path: "src/main.ts" }, + kind: "read", + status: "in_progress", + locations: [], + }); + setActiveHandler(handler); + + await AcpStreamHandler.cancelPrompt(); + + expect(onToolComplete).toHaveBeenCalledWith("read_text_file", "tool-1", undefined, "Cancelled"); + expect(onComplete).toHaveBeenCalledOnce(); + expect(mockedInvoke).toHaveBeenCalledWith("cancel_acp_prompt"); + }); + + it("ignores late events after a cancelled turn is force-stopped", async () => { + const onComplete = vi.fn(); + const onToolComplete = vi.fn(); + const onToolUse = vi.fn(); + const handler = new AcpStreamHandler( + "codex", + { + onChunk: vi.fn(), + onComplete, + onError: vi.fn(), + onToolUse, + onToolComplete, + }, + "chat-1", + ); + + handleAcpEvent(handler, { + type: "tool_start", + sessionId: "session-1", + toolName: "read_text_file", + toolId: "tool-1", + input: { path: "src/main.ts" }, + kind: "read", + status: "in_progress", + locations: [], + }); + setActiveHandler(handler); + + await AcpStreamHandler.cancelPrompt(); + handleAcpEvent(handler, { + type: "tool_start", + sessionId: "session-1", + toolName: "write_text_file", + toolId: "tool-2", + input: { path: "src/main.ts" }, + kind: "edit", + status: "in_progress", + locations: [], + }); + + expect(onToolUse).toHaveBeenCalledOnce(); + expect(onToolComplete).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/ai/tests/acp-permission.test.ts b/src/features/ai/tests/acp-permission.test.ts new file mode 100644 index 000000000..84cdcf1af --- /dev/null +++ b/src/features/ai/tests/acp-permission.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { invoke } from "@tauri-apps/api/core"; +import { AcpStreamHandler } from "../services/acp-stream-handler"; +import type { AcpEvent } from "../types/acp"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +vi.mock("@/features/ai/store/store", () => ({ + useAIChatStore: { + getState: () => ({ + acpStatus: null, + getChatById: () => null, + getCurrentChat: () => null, + setAcpStatus: vi.fn(), + setChatAcpSessionId: vi.fn(), + setAvailableSlashCommands: vi.fn(), + setSessionConfigOptions: vi.fn(), + setSessionModeState: vi.fn(), + setCurrentModeId: vi.fn(), + }), + }, +})); + +vi.mock("@/features/editor/stores/buffer-store", () => ({ + useBufferStore: { + getState: () => ({ + actions: { + openWebViewerBuffer: vi.fn(), + openTerminalBuffer: vi.fn(), + }, + }), + }, +})); + +vi.mock("@/features/window/stores/project-store", () => ({ + useProjectStore: { + getState: () => ({ + rootFolderPath: "/repo", + }), + }, +})); + +vi.mock("@/features/ai/lib/acp-session-info", () => ({ + getChatTitleFromSessionInfo: (_currentTitle: string | undefined, nextTitle: string) => nextTitle, +})); + +vi.mock("../utils/ai-context-builder", () => ({ + buildContextPrompt: () => "", +})); + +type TestableAcpStreamHandler = { + handleAcpEvent: (event: unknown) => void; +}; + +const mockedInvoke = vi.mocked(invoke); + +const handleAcpEvent = (handler: AcpStreamHandler, event: AcpEvent) => { + (handler as unknown as TestableAcpStreamHandler).handleAcpEvent(event); +}; + +const permissionEvent: AcpEvent = { + type: "permission_request", + requestId: "request-1", + permissionType: "tool_call", + resource: "tool-1", + description: "Run command (tool-1)", + options: [ + { + id: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + { + id: "reject-once", + name: "Reject once", + kind: "reject_once", + }, + ], +}; + +describe("AcpStreamHandler permission requests", () => { + afterEach(() => { + mockedInvoke.mockReset(); + vi.clearAllMocks(); + }); + + it("routes permission requests to the permission handler", () => { + const onEvent = vi.fn(); + const onPermissionRequest = vi.fn(); + const handler = new AcpStreamHandler( + "codex", + { + onChunk: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + onEvent, + onPermissionRequest, + }, + "chat-1", + ); + + handleAcpEvent(handler, permissionEvent); + + expect(onEvent).toHaveBeenCalledWith(permissionEvent); + expect(onPermissionRequest).toHaveBeenCalledWith(permissionEvent); + expect(mockedInvoke).not.toHaveBeenCalled(); + }); + + it("auto-rejects permission requests when no permission handler is registered", async () => { + mockedInvoke.mockResolvedValue(undefined); + const handler = new AcpStreamHandler( + "codex", + { + onChunk: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + }, + "chat-1", + ); + + handleAcpEvent(handler, permissionEvent); + await vi.waitFor(() => { + expect(mockedInvoke).toHaveBeenCalledWith("respond_acp_permission", { + args: { + requestId: "request-1", + approved: false, + cancelled: false, + optionId: undefined, + }, + }); + }); + }); +}); diff --git a/src/features/ai/tests/acp-provider-errors.test.ts b/src/features/ai/tests/acp-provider-errors.test.ts new file mode 100644 index 000000000..ab8f419f4 --- /dev/null +++ b/src/features/ai/tests/acp-provider-errors.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { classifyAcpProviderError } from "../lib/acp-provider-errors"; + +describe("classifyAcpProviderError", () => { + it("classifies provider setup errors surfaced after launch", () => { + const result = classifyAcpProviderError( + "[error] No API key found. Set the Z_AI_API_KEY environment variable, or run `glm-acp-agent --setup` to store one.", + ); + + expect(result).toMatchObject({ + code: "PROVIDER_SETUP_REQUIRED", + title: "Provider Setup Required", + activityLabel: "Provider setup required", + }); + }); + + it("classifies ACP protocol authentication errors separately", () => { + const result = classifyAcpProviderError("Authentication required", "Method not implemented"); + + expect(result).toMatchObject({ + code: "AUTH_REQUIRED", + title: "Authentication Required", + activityLabel: "Agent authentication required", + }); + expect(result?.detail).toContain("does not implement"); + }); + + it("ignores ordinary provider runtime errors", () => { + expect(classifyAcpProviderError("Agent returned EMPTY_RESPONSE")).toBeNull(); + }); + + it("honors backend provider setup classification", () => { + const result = classifyAcpProviderError( + "Provider exited during prompt", + "", + "provider_setup_required", + ); + + expect(result?.code).toBe("PROVIDER_SETUP_REQUIRED"); + }); +}); diff --git a/src/features/ai/tests/acp-usage.test.ts b/src/features/ai/tests/acp-usage.test.ts new file mode 100644 index 000000000..9d8c6a5ab --- /dev/null +++ b/src/features/ai/tests/acp-usage.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { AcpStreamHandler } from "../services/acp-stream-handler"; +import type { AcpEvent } from "../types/acp"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +vi.mock("@/features/ai/store/store", () => ({ + useAIChatStore: { + getState: () => ({ + acpStatus: null, + getChatById: () => null, + getCurrentChat: () => null, + setAcpStatus: vi.fn(), + setChatAcpSessionId: vi.fn(), + setAvailableSlashCommands: vi.fn(), + setSessionConfigOptions: vi.fn(), + setSessionModeState: vi.fn(), + setCurrentModeId: vi.fn(), + }), + }, +})); + +vi.mock("@/features/editor/stores/buffer-store", () => ({ + useBufferStore: { + getState: () => ({ + actions: { + openWebViewerBuffer: vi.fn(), + openTerminalBuffer: vi.fn(), + }, + }), + }, +})); + +vi.mock("@/features/window/stores/project-store", () => ({ + useProjectStore: { + getState: () => ({ + rootFolderPath: "/repo", + }), + }, +})); + +vi.mock("@/features/ai/lib/acp-session-info", () => ({ + getChatTitleFromSessionInfo: (_currentTitle: string | undefined, nextTitle: string) => nextTitle, +})); + +vi.mock("../utils/ai-context-builder", () => ({ + buildContextPrompt: () => "", +})); + +type TestableAcpStreamHandler = { + handleAcpEvent: (event: unknown) => void; +}; + +const handleAcpEvent = (handler: AcpStreamHandler, event: AcpEvent) => { + (handler as unknown as TestableAcpStreamHandler).handleAcpEvent(event); +}; + +describe("AcpStreamHandler usage updates", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("passes usage updates through the generic ACP event stream without mutating chat output", () => { + const onEvent = vi.fn(); + const onChunk = vi.fn(); + const onToolUse = vi.fn(); + const onToolComplete = vi.fn(); + const onComplete = vi.fn(); + const onError = vi.fn(); + const handler = new AcpStreamHandler( + "codex", + { + onChunk, + onComplete, + onError, + onEvent, + onToolUse, + onToolComplete, + }, + "chat-1", + ); + const event: AcpEvent = { + type: "usage_update", + sessionId: "session-1", + used: 1234, + size: 200000, + }; + + handleAcpEvent(handler, event); + + expect(onEvent).toHaveBeenCalledWith(event); + expect(onChunk).not.toHaveBeenCalled(); + expect(onToolUse).not.toHaveBeenCalled(); + expect(onToolComplete).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/ai/tests/error-block-parsing.test.ts b/src/features/ai/tests/error-block-parsing.test.ts new file mode 100644 index 000000000..111a146c9 --- /dev/null +++ b/src/features/ai/tests/error-block-parsing.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; +import { extractProviderSetupCommand, parseErrorBlockData } from "../lib/error-block-data"; + +describe("error block parsing", () => { + it("keeps multiline details intact", () => { + const parsed = parseErrorBlockData(`title: Provider Setup Required +code: PROVIDER_SETUP_REQUIRED +message: Provider needs setup +details: First line +second line +third line`); + + expect(parsed).toEqual({ + title: "Provider Setup Required", + code: "PROVIDER_SETUP_REQUIRED", + message: "Provider needs setup", + details: "First line\nsecond line\nthird line", + }); + }); + + it("extracts setup commands without including prose", () => { + expect( + extractProviderSetupCommand( + "[error] No API key found. Set Z_AI_API_KEY, or run `glm-acp-agent --setup` to store one.", + ), + ).toBe("glm-acp-agent --setup"); + }); +}); diff --git a/src/features/ai/types/acp.ts b/src/features/ai/types/acp.ts index aea4140de..015c3f8a0 100644 --- a/src/features/ai/types/acp.ts +++ b/src/features/ai/types/acp.ts @@ -7,12 +7,15 @@ export interface AgentConfig { binaryPath: string | null; args: string[]; envVars: Record; + defaultMode: string | null; + defaultModel: string | null; icon: string | null; description: string | null; installed: boolean; installRuntime: "node" | "python" | "go" | "rust" | "binary" | null; installPackage: string | null; canInstall: boolean; + updateAvailable?: boolean; } export interface AcpAgentStatus { @@ -232,6 +235,7 @@ export type AcpEvent = type: "error"; sessionId: string | null; error: string; + errorKind?: "authentication_required" | "provider_setup_required" | null; } | { type: "status_changed"; @@ -268,6 +272,12 @@ export type AcpEvent = title: string | null; updatedAt: string | null; } + | { + type: "usage_update"; + sessionId: string; + used: number; + size: number; + } | { type: "prompt_complete"; sessionId: string; diff --git a/src/features/settings/components/tabs/extensions-settings.tsx b/src/features/settings/components/tabs/extensions-settings.tsx index 0006427ab..60165a9ae 100644 --- a/src/features/settings/components/tabs/extensions-settings.tsx +++ b/src/features/settings/components/tabs/extensions-settings.tsx @@ -53,6 +53,7 @@ interface UnifiedExtension { marketplaceSkill?: MarketplaceSkill; agentId?: string; canInstall?: boolean; + updateAvailable?: boolean; packageSize?: number; contributionSummary?: string[]; } @@ -146,11 +147,11 @@ const ExtensionRow = ({ : extension.extensions?.map((ext) => `.${ext}`); return ( -
+
{extension.name} - + {getCategoryLabel(extension.category)} {extension.version && ( @@ -160,7 +161,7 @@ const ExtensionRow = ({ Local override @@ -193,21 +194,21 @@ const ExtensionRow = ({
{extension.isBundled ? ( -
- +
+ Built-in
) : isInstalling ? ( Installing ) : isUnavailableAgent ? ( -
+
) : extension.isInstalled ? ( -
+
{(hasUpdate || hasRuntimeIssue) && onUpdate && (
) : ( -
+
@@ -302,6 +303,7 @@ export const ExtensionsSettings = () => { runtimeIssues: ext.runtimeIssues, agentId: contribution.id, canInstall: agent?.canInstall ?? Boolean(contribution.install), + updateAvailable: agent?.updateAvailable ?? false, contributionSummary: [ `agent:${contribution.id}`, agent?.binaryName ?? contribution.binaryName, @@ -483,6 +485,7 @@ export const ExtensionsSettings = () => { isMarketplace: true, agentId: agent.id, canInstall: agent.canInstall, + updateAvailable: agent.updateAvailable ?? false, contributionSummary: [`agent:${agent.id}`, agent.binaryName], }); } @@ -506,6 +509,51 @@ export const ExtensionsSettings = () => { }, []); const handleUpdate = async (extension: UnifiedExtension) => { + if (extension.category === "agent") { + if (extension.canInstall === false) { + showToast({ + message: `${extension.name} cannot be reinstalled automatically`, + type: "error", + duration: 5000, + }); + return; + } + + const agentId = extension.agentId ?? extension.id.replace(/^agent:/, ""); + setInstallingAgentIds((current) => new Set(current).add(agentId)); + + try { + const installedAgent = await invoke("install_acp_agent", { agentId }); + setAgents((current) => { + const next = new Map(current.map((agent) => [agent.id, agent])); + next.set(installedAgent.id, installedAgent); + return Array.from(next.values()); + }); + void loadAgents(); + showToast({ + message: `${extension.name} reinstalled successfully`, + type: "success", + duration: 3000, + }); + } catch (error) { + console.error(`Failed to reinstall ${extension.name}:`, error); + showToast({ + message: `Failed to reinstall ${extension.name}: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + type: "error", + duration: 5000, + }); + } finally { + setInstallingAgentIds((current) => { + const next = new Set(current); + next.delete(agentId); + return next; + }); + } + return; + } + if (extension.category === "skill") { if (!extension.skill || !extension.marketplaceSkill) return; @@ -756,6 +804,7 @@ export const ExtensionsSettings = () => { ); const hasExtensionUpdate = (extension: UnifiedExtension) => + (extension.category === "agent" && Boolean(extension.updateAvailable)) || extensionsWithUpdates.has(extension.id) || Boolean( extension.skill && diff --git a/src/features/settings/config/default-settings.ts b/src/features/settings/config/default-settings.ts index 22dc1a61e..89c8990c3 100644 --- a/src/features/settings/config/default-settings.ts +++ b/src/features/settings/config/default-settings.ts @@ -56,7 +56,7 @@ export const defaultSettings: Settings = { nativeMenuBar: false, compactMenuBar: true, sidebarTabsPosition: "top", - titleBarProjectMode: "window", + titleBarProjectMode: "tabs", headerTrailingItemsOrder: [...HEADER_TRAILING_ITEM_IDS], sidebarActivityItemsOrder: [...SIDEBAR_ACTIVITY_ITEM_IDS], footerLeadingItemsOrder: [...FOOTER_LEADING_ITEM_IDS], @@ -76,6 +76,7 @@ export const defaultSettings: Settings = { aiAutocompleteCustomModelId: "", aiDefaultSessionMode: "", aiSkills: [], + agentServers: {}, ollamaBaseUrl: "http://localhost:11434", // Layout sidebarWidth: 220, @@ -164,6 +165,9 @@ export function getDefaultSettingsSnapshot(): Settings { footerLeadingItemsOrder: [...defaultSettings.footerLeadingItemsOrder], footerTrailingItemsOrder: [...defaultSettings.footerTrailingItemsOrder], aiSkills: defaultSettings.aiSkills.map((skill) => ({ ...skill })), + agentServers: Object.fromEntries( + Object.entries(defaultSettings.agentServers).map(([id, agent]) => [id, { ...agent }]), + ), uiFontSize: normalizeUiFontSize(defaultSettings.uiFontSize), }; } diff --git a/src/features/settings/lib/settings-normalization.ts b/src/features/settings/lib/settings-normalization.ts index f5048a080..3ec31da26 100644 --- a/src/features/settings/lib/settings-normalization.ts +++ b/src/features/settings/lib/settings-normalization.ts @@ -77,6 +77,11 @@ function normalizeBaseUrl(value: string | undefined): string { } const MAX_SYNCED_AI_SKILLS = 200; +const MAX_AGENT_SERVERS = 100; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} function normalizeAISkills(skills: Settings["aiSkills"]): Settings["aiSkills"] { if (!Array.isArray(skills)) { @@ -146,6 +151,83 @@ function normalizeAISkills(skills: Settings["aiSkills"]): Settings["aiSkills"] { })); } +function normalizeEnv(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const env = Object.fromEntries( + Object.entries(value) + .filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && + entry[0].trim().length > 0 && + typeof entry[1] === "string", + ) + .map(([key, envValue]) => [key.trim().slice(0, 160), envValue]), + ); + + return Object.keys(env).length > 0 ? env : undefined; +} + +function normalizeAgentServers(value: Settings["agentServers"]): Settings["agentServers"] { + if (!isRecord(value)) { + return {}; + } + + const entries: Array<[string, Settings["agentServers"][string]]> = []; + + for (const [id, server] of Object.entries(value) + .filter(([id, server]) => typeof id === "string" && id.trim().length > 0 && isRecord(server)) + .slice(0, MAX_AGENT_SERVERS)) { + const trimmedId = id.trim().slice(0, 160); + const env = normalizeEnv(server.env); + const defaultMode = + typeof server.defaultMode === "string" && server.defaultMode.trim() + ? server.defaultMode.trim().slice(0, 160) + : undefined; + const defaultModel = + typeof server.defaultModel === "string" && server.defaultModel.trim() + ? server.defaultModel.trim().slice(0, 240) + : undefined; + + if (server.type === "custom") { + if (typeof server.command !== "string" || !server.command.trim()) { + continue; + } + const args = Array.isArray(server.args) + ? server.args.filter((arg): arg is string => typeof arg === "string") + : undefined; + entries.push([ + trimmedId, + { + type: "custom", + command: server.command.trim(), + ...(args ? { args } : {}), + ...(env ? { env } : {}), + ...(defaultMode ? { defaultMode } : {}), + ...(defaultModel ? { defaultModel } : {}), + }, + ]); + continue; + } + + if (server.type === "registry") { + entries.push([ + trimmedId, + { + type: "registry", + ...(env ? { env } : {}), + ...(defaultMode ? { defaultMode } : {}), + ...(defaultModel ? { defaultModel } : {}), + }, + ]); + } + } + + return Object.fromEntries(entries); +} + function normalizeAISettings(settings: Settings): Settings { const normalizedSettings = { ...settings }; const provider = @@ -191,6 +273,7 @@ function normalizeAISettings(settings: Settings): Settings { normalizedSettings.aiAutocompleteCustomModelId = normalizedSettings.aiAutocompleteCustomModelId?.trim() || ""; normalizedSettings.aiSkills = normalizeAISkills(normalizedSettings.aiSkills); + normalizedSettings.agentServers = normalizeAgentServers(normalizedSettings.agentServers); return normalizedSettings; } @@ -310,6 +393,10 @@ export function normalizeSettingValue( return normalizeAISkills(value as Settings["aiSkills"]) as Settings[K]; } + if (key === "agentServers") { + return normalizeAgentServers(value as Settings["agentServers"]) as Settings[K]; + } + if (key === "aiCustomBaseUrl") { return normalizeBaseUrl(value as string) as Settings[K]; } diff --git a/src/features/settings/tests/settings-import-export.test.ts b/src/features/settings/tests/settings-import-export.test.ts index 522a68667..e358c79be 100644 --- a/src/features/settings/tests/settings-import-export.test.ts +++ b/src/features/settings/tests/settings-import-export.test.ts @@ -46,4 +46,28 @@ describe("settings import/export", () => { expect(imported?.wordWrap).toBe(true); }); + + it("imports ACP agent server settings", () => { + const imported = parseSettingsImportJson( + JSON.stringify({ + agentServers: { + "codex-acp": { + type: "registry", + env: { + CODEX_HOME: "/tmp/codex", + }, + defaultMode: "plan", + }, + }, + }), + ); + + expect(imported?.agentServers["codex-acp"]).toEqual({ + type: "registry", + env: { + CODEX_HOME: "/tmp/codex", + }, + defaultMode: "plan", + }); + }); }); diff --git a/src/features/settings/tests/settings-normalization.test.ts b/src/features/settings/tests/settings-normalization.test.ts index a8585e750..5ab51a89e 100644 --- a/src/features/settings/tests/settings-normalization.test.ts +++ b/src/features/settings/tests/settings-normalization.test.ts @@ -112,4 +112,51 @@ describe("settings normalization", () => { upstreamUpdatedAt: "2026-04-01T00:00:00.000Z", }); }); + + it("normalizes ACP agent server settings", () => { + const normalized = normalizeSettingValue("agentServers", { + " codex-acp ": { + type: "registry", + env: { + CODEX_API_KEY: "secret", + EMPTY_ALLOWED: "", + ignored: 1, + }, + defaultMode: " plan ", + defaultModel: " gpt-5.5 ", + }, + " custom-agent ": { + type: "custom", + command: " node ", + args: ["agent.js", 123], + env: { + CUSTOM_ENV: "1", + }, + }, + broken: { + type: "custom", + command: " ", + }, + } as unknown as ReturnType["agentServers"]); + + expect(normalized).toEqual({ + "codex-acp": { + type: "registry", + env: { + CODEX_API_KEY: "secret", + EMPTY_ALLOWED: "", + }, + defaultMode: "plan", + defaultModel: "gpt-5.5", + }, + "custom-agent": { + type: "custom", + command: "node", + args: ["agent.js"], + env: { + CUSTOM_ENV: "1", + }, + }, + }); + }); }); diff --git a/src/features/settings/types/settings.ts b/src/features/settings/types/settings.ts index 1728947ef..3c5f531eb 100644 --- a/src/features/settings/types/settings.ts +++ b/src/features/settings/types/settings.ts @@ -9,6 +9,22 @@ import type { export type Theme = string; +export type AcpAgentServerSettings = + | { + type: "custom"; + command: string; + args?: string[]; + env?: Record; + defaultMode?: string; + defaultModel?: string; + } + | { + type: "registry"; + env?: Record; + defaultMode?: string; + defaultModel?: string; + }; + export interface Settings { // General autoSave: boolean; @@ -65,6 +81,7 @@ export interface Settings { aiAutocompleteCustomModelId: string; aiDefaultSessionMode: string; aiSkills: AIChatSkill[]; + agentServers: Record; ollamaBaseUrl: string; // Layout sidebarWidth: number;