mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Show all ACP runtimes with install status and one-click install (#724)
This commit is contained in:
@@ -45,27 +45,27 @@ const overrides = new Map([
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
|
||||
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission
|
||||
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
|
||||
["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
|
||||
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs)
|
||||
["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
|
||||
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
|
||||
["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
|
||||
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
|
||||
["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh
|
||||
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
|
||||
["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation
|
||||
["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
|
||||
["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states
|
||||
["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching
|
||||
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
|
||||
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
|
||||
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
|
||||
["src/features/agents/ui/PersonaDialog.tsx", 515], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings
|
||||
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail
|
||||
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
|
||||
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
|
||||
["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
|
||||
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
|
||||
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
|
||||
["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block
|
||||
@@ -77,7 +77,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
|
||||
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
|
||||
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
|
||||
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep
|
||||
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
|
||||
@@ -1,19 +1,286 @@
|
||||
use std::io::Read;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
command_availability, discover_local_acp_providers, AcpProviderInfo,
|
||||
DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo,
|
||||
command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest,
|
||||
InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
nostr_convert,
|
||||
relay::query_relay,
|
||||
};
|
||||
|
||||
fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static ACTIVE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
ACTIVE.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
discover_local_acp_providers()
|
||||
pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
|
||||
crate::managed_agents::clear_resolve_cache();
|
||||
crate::managed_agents::discover_acp_providers()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_acp_runtime(provider_id: String) -> Result<InstallRuntimeResult, String> {
|
||||
tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&provider_id))
|
||||
.await
|
||||
.map_err(|e| format!("install task panicked: {e}"))?
|
||||
}
|
||||
|
||||
/// Err(_) = infrastructure failure (panic, concurrency guard).
|
||||
/// Ok({success: false}) = an install step failed (stderr captured in steps).
|
||||
fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResult, String> {
|
||||
// Prevent concurrent installs for the same provider.
|
||||
{
|
||||
let mut set = active_installs()
|
||||
.lock()
|
||||
.map_err(|_| "install lock poisoned".to_string())?;
|
||||
if !set.insert(provider_id.to_string()) {
|
||||
return Err(format!(
|
||||
"an install is already in progress for {provider_id}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
struct Guard(String);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut set) = active_installs().lock() {
|
||||
set.remove(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = Guard(provider_id.to_string());
|
||||
|
||||
let provider = crate::managed_agents::known_acp_provider_exact(provider_id)
|
||||
.ok_or_else(|| format!("unknown provider: {provider_id}"))?;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
|
||||
// Phase 1: Install CLI if missing and commands are available.
|
||||
if let Some(cli) = provider.underlying_cli {
|
||||
if crate::managed_agents::resolve_command(cli, None).is_none() {
|
||||
for cmd in provider.cli_install_commands {
|
||||
let result = run_install_command("cli", cmd);
|
||||
let success = result.success;
|
||||
steps.push(result);
|
||||
if !success {
|
||||
return Ok(InstallRuntimeResult {
|
||||
success: false,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Install adapter if missing and commands are available.
|
||||
let adapter_found = provider
|
||||
.commands
|
||||
.iter()
|
||||
.any(|cmd| crate::managed_agents::resolve_command(cmd, None).is_some());
|
||||
if !adapter_found {
|
||||
for cmd in provider.adapter_install_commands {
|
||||
let result = run_install_command("adapter", cmd);
|
||||
let success = result.success;
|
||||
steps.push(result);
|
||||
if !success {
|
||||
return Ok(InstallRuntimeResult {
|
||||
success: false,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the resolve cache so the next discovery picks up new binaries.
|
||||
crate::managed_agents::clear_resolve_cache();
|
||||
|
||||
Ok(InstallRuntimeResult {
|
||||
success: true,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_install_command(step: &str, command: &str) -> InstallStepResult {
|
||||
let shell_path = crate::managed_agents::login_shell_path();
|
||||
let shell = if std::path::Path::new("/bin/zsh").exists() {
|
||||
"/bin/zsh"
|
||||
} else {
|
||||
"/bin/bash"
|
||||
};
|
||||
|
||||
let mut cmd = std::process::Command::new(shell);
|
||||
cmd.args(["-l", "-c", command]);
|
||||
|
||||
// Strip hermit env vars so npm/node use the user's normal registry and
|
||||
// global prefix rather than the project-local hermit-managed paths.
|
||||
cmd.env_remove("NPM_CONFIG_PREFIX");
|
||||
cmd.env_remove("NPM_CONFIG_CACHE");
|
||||
cmd.env_remove("COREPACK_HOME");
|
||||
|
||||
if let Some(ref path) = shell_path {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
|
||||
// Detach from the controlling terminal so install scripts that read from
|
||||
// /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin
|
||||
// (which is /dev/null) instead of blocking forever.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = match cmd
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
return InstallStepResult {
|
||||
step: step.to_string(),
|
||||
command: command.to_string(),
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: format!("failed to spawn shell: {e}"),
|
||||
exit_code: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Drain stdout/stderr on background threads to prevent pipe buffer deadlock.
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_thread = std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = pipe.read_to_string(&mut buf);
|
||||
}
|
||||
buf
|
||||
});
|
||||
let stderr_thread = std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = pipe.read_to_string(&mut buf);
|
||||
}
|
||||
buf
|
||||
});
|
||||
|
||||
// Save the PID before moving `child` into the wait thread so we can
|
||||
// kill the process on timeout.
|
||||
let child_pid = child.id();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let wait_thread = std::thread::spawn(move || {
|
||||
let status = child.wait();
|
||||
let _ = tx.send(status);
|
||||
});
|
||||
|
||||
// 5-minute timeout for install commands.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
// Timeout: kill the child process via its PID, then join all
|
||||
// threads so nothing leaks.
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child_pid as i32, libc::SIGTERM);
|
||||
}
|
||||
drop(rx);
|
||||
let _ = wait_thread.join();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
return InstallStepResult {
|
||||
step: step.to_string(),
|
||||
command: command.to_string(),
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: "install command timed out after 5 minutes".to_string(),
|
||||
exit_code: None,
|
||||
};
|
||||
}
|
||||
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) {
|
||||
Ok(Ok(status)) => {
|
||||
let _ = wait_thread.join();
|
||||
let stdout = stdout_thread.join().unwrap_or_default();
|
||||
let stderr_raw = stderr_thread.join().unwrap_or_default();
|
||||
return InstallStepResult {
|
||||
step: step.to_string(),
|
||||
command: command.to_string(),
|
||||
success: status.success(),
|
||||
stdout: truncate_output(stdout),
|
||||
stderr: truncate_output(stderr_raw),
|
||||
exit_code: status.code(),
|
||||
};
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let _ = wait_thread.join();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
return InstallStepResult {
|
||||
step: step.to_string(),
|
||||
command: command.to_string(),
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: format!("failed to check process status: {e}"),
|
||||
exit_code: None,
|
||||
};
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// Still running; loop and check deadline again.
|
||||
continue;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// wait_thread dropped sender without sending — shouldn't happen.
|
||||
let _ = wait_thread.join();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
return InstallStepResult {
|
||||
step: step.to_string(),
|
||||
command: command.to_string(),
|
||||
success: false,
|
||||
stdout: String::new(),
|
||||
stderr: "internal error: wait thread disconnected".to_string(),
|
||||
exit_code: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap output to head + tail to avoid flooding the UI with large error dumps,
|
||||
/// while preserving the most useful parts of the output.
|
||||
fn truncate_output(s: String) -> String {
|
||||
const HEAD: usize = 512;
|
||||
const TAIL: usize = 1024;
|
||||
const LIMIT: usize = HEAD + TAIL;
|
||||
if s.len() <= LIMIT {
|
||||
return s;
|
||||
}
|
||||
let head_end = s.floor_char_boundary(HEAD);
|
||||
let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL));
|
||||
let omitted = tail_start - head_end;
|
||||
format!(
|
||||
"{}\n... ({omitted} bytes omitted) ...\n{}",
|
||||
&s[..head_end],
|
||||
&s[tail_start..]
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -466,6 +466,7 @@ pub fn run() {
|
||||
get_relay_http_url,
|
||||
get_media_proxy_port,
|
||||
discover_acp_providers,
|
||||
install_acp_runtime,
|
||||
discover_managed_agent_prereqs,
|
||||
sign_event,
|
||||
decrypt_observer_event,
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::process::Command;
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo};
|
||||
use crate::managed_agents::{
|
||||
AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailabilityInfo,
|
||||
};
|
||||
|
||||
pub(crate) struct KnownAcpProvider {
|
||||
pub id: &'static str,
|
||||
@@ -15,6 +17,18 @@ pub(crate) struct KnownAcpProvider {
|
||||
pub mcp_command: Option<&'static str>,
|
||||
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
|
||||
pub mcp_hooks: bool,
|
||||
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
|
||||
pub underlying_cli: Option<&'static str>,
|
||||
/// Shell commands to install the runtime CLI itself (run sequentially).
|
||||
pub cli_install_commands: &'static [&'static str],
|
||||
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
|
||||
pub adapter_install_commands: &'static [&'static str],
|
||||
/// Link to docs/repo for manual instructions.
|
||||
pub install_instructions_url: &'static str,
|
||||
/// Human-readable hint about installing the CLI binary.
|
||||
pub cli_install_hint: &'static str,
|
||||
/// Human-readable hint about installing the ACP adapter.
|
||||
pub adapter_install_hint: &'static str,
|
||||
}
|
||||
|
||||
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
|
||||
@@ -54,6 +68,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
avatar_url: GOOSE_AVATAR_URL,
|
||||
mcp_command: None,
|
||||
mcp_hooks: false,
|
||||
underlying_cli: Some("goose"),
|
||||
cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
|
||||
adapter_install_commands: &[],
|
||||
install_instructions_url: "https://block.github.io/goose/",
|
||||
cli_install_hint: "Install Goose via the official install script.",
|
||||
adapter_install_hint: "",
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "claude",
|
||||
@@ -63,6 +83,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
avatar_url: CLAUDE_CODE_AVATAR_URL,
|
||||
mcp_command: None,
|
||||
mcp_hooks: false,
|
||||
underlying_cli: Some("claude"),
|
||||
cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
|
||||
adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"],
|
||||
install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
|
||||
cli_install_hint: "Install the Claude Code CLI via the official install script.",
|
||||
adapter_install_hint: "Install the Claude Code ACP adapter via npm.",
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "codex",
|
||||
@@ -72,6 +98,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
avatar_url: CODEX_AVATAR_URL,
|
||||
mcp_command: None,
|
||||
mcp_hooks: false,
|
||||
underlying_cli: Some("codex"),
|
||||
cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
|
||||
adapter_install_commands: &["npm install -g @zed-industries/codex-acp"],
|
||||
install_instructions_url: "https://github.com/zed-industries/codex-acp",
|
||||
cli_install_hint: "Install the Codex CLI via the official install script.",
|
||||
adapter_install_hint: "Install the Codex ACP adapter via npm.",
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "sprout-agent",
|
||||
@@ -81,6 +113,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
avatar_url: SPROUT_AGENT_AVATAR_URL,
|
||||
mcp_command: Some("sprout-dev-mcp"),
|
||||
mcp_hooks: true,
|
||||
underlying_cli: None,
|
||||
cli_install_commands: &[],
|
||||
adapter_install_commands: &[],
|
||||
install_instructions_url: "https://github.com/block/sprout",
|
||||
cli_install_hint: "Ships with the Sprout desktop app.",
|
||||
adapter_install_hint: "",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -144,6 +182,10 @@ pub(crate) fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvi
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn known_acp_provider_exact(id: &str) -> Option<&'static KnownAcpProvider> {
|
||||
KNOWN_ACP_PROVIDERS.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
fn default_agent_args(command: &str) -> Option<Vec<String>> {
|
||||
match normalize_command_identity(command).as_str() {
|
||||
"goose" => Some(vec!["acp".to_string()]),
|
||||
@@ -219,15 +261,19 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option<P
|
||||
.find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<PathBuf>>>
|
||||
{
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Resolve a command to an absolute path, caching results for the app lifetime.
|
||||
/// The cache eliminates redundant login-shell spawns when multiple agents share
|
||||
/// the same binaries (e.g. `npx`, `uvx`).
|
||||
pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let cache = resolve_cache();
|
||||
|
||||
// Fast path: return cached result without allocating a key.
|
||||
if let Ok(guard) = cache.lock() {
|
||||
@@ -248,6 +294,12 @@ pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf
|
||||
result
|
||||
}
|
||||
|
||||
/// Clear the resolve_command cache so that newly-installed binaries are detected.
|
||||
pub fn clear_resolve_cache() {
|
||||
let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.clear();
|
||||
}
|
||||
|
||||
fn resolve_command_uncached(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
|
||||
if let Some(path) = resolve_workspace_command(command, app) {
|
||||
return Some(path);
|
||||
@@ -349,22 +401,96 @@ pub fn missing_command_message(command: &str, role: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
fn classify_provider(
|
||||
adapter_result: Option<(&str, PathBuf)>,
|
||||
underlying_cli: Option<&str>,
|
||||
underlying_cli_found: bool,
|
||||
) -> (AcpAvailabilityStatus, Option<String>, Option<String>) {
|
||||
if let Some((cmd, path)) = adapter_result {
|
||||
if underlying_cli.is_some() && !underlying_cli_found {
|
||||
(
|
||||
AcpAvailabilityStatus::CliMissing,
|
||||
Some(cmd.to_string()),
|
||||
Some(path.display().to_string()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
AcpAvailabilityStatus::Available,
|
||||
Some(cmd.to_string()),
|
||||
Some(path.display().to_string()),
|
||||
)
|
||||
}
|
||||
} else if underlying_cli.is_some() && underlying_cli_found {
|
||||
(AcpAvailabilityStatus::AdapterMissing, None, None)
|
||||
} else {
|
||||
(AcpAvailabilityStatus::NotInstalled, None, None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
|
||||
KNOWN_ACP_PROVIDERS
|
||||
.iter()
|
||||
.filter_map(|provider| {
|
||||
provider
|
||||
.map(|provider| {
|
||||
// Try to find the ACP adapter binary.
|
||||
let adapter_result = provider
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|command| find_command(command).map(|path| (*command, path)))
|
||||
.map(|(command, binary_path)| AcpProviderInfo {
|
||||
id: provider.id.to_string(),
|
||||
label: provider.label.to_string(),
|
||||
command: command.to_string(),
|
||||
binary_path: binary_path.display().to_string(),
|
||||
default_args: normalize_agent_args(command, Vec::new()),
|
||||
mcp_command: provider.mcp_command.map(str::to_string),
|
||||
})
|
||||
.find_map(|command| find_command(command).map(|path| (*command, path)));
|
||||
|
||||
let underlying_cli_found = provider
|
||||
.underlying_cli
|
||||
.map(|cli| find_command(cli).is_some())
|
||||
.unwrap_or(false);
|
||||
let (availability, command, binary_path) = classify_provider(
|
||||
adapter_result,
|
||||
provider.underlying_cli,
|
||||
underlying_cli_found,
|
||||
);
|
||||
|
||||
let underlying_cli_path = provider
|
||||
.underlying_cli
|
||||
.and_then(|cli| find_command(cli))
|
||||
.map(|p| p.display().to_string());
|
||||
|
||||
let default_args = command
|
||||
.as_deref()
|
||||
.map(|cmd| normalize_agent_args(cmd, Vec::new()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let can_auto_install = !provider.cli_install_commands.is_empty()
|
||||
|| !provider.adapter_install_commands.is_empty();
|
||||
|
||||
let cli_hint = provider.cli_install_hint;
|
||||
let adapter_hint = provider.adapter_install_hint;
|
||||
let install_hint = match availability {
|
||||
AcpAvailabilityStatus::Available => cli_hint.to_string(),
|
||||
AcpAvailabilityStatus::CliMissing => cli_hint.to_string(),
|
||||
AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(),
|
||||
AcpAvailabilityStatus::NotInstalled => {
|
||||
if !cli_hint.is_empty() && !adapter_hint.is_empty() {
|
||||
format!("{cli_hint} {adapter_hint}")
|
||||
} else if !cli_hint.is_empty() {
|
||||
cli_hint.to_string()
|
||||
} else {
|
||||
adapter_hint.to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AcpProviderCatalogEntry {
|
||||
id: provider.id.to_string(),
|
||||
label: provider.label.to_string(),
|
||||
avatar_url: provider.avatar_url.to_string(),
|
||||
availability,
|
||||
command,
|
||||
binary_path,
|
||||
default_args,
|
||||
mcp_command: provider.mcp_command.map(str::to_string),
|
||||
install_hint,
|
||||
install_instructions_url: provider.install_instructions_url.to_string(),
|
||||
can_auto_install,
|
||||
underlying_cli_path,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -376,10 +502,13 @@ pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{
|
||||
find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
|
||||
classify_provider, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
|
||||
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, SPROUT_AGENT_AVATAR_URL,
|
||||
};
|
||||
use crate::managed_agents::AcpAvailabilityStatus;
|
||||
|
||||
#[test]
|
||||
fn resolves_known_avatar_for_bare_command() {
|
||||
@@ -470,4 +599,52 @@ mod tests {
|
||||
"shell lookup must not execute injected commands"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_available_when_adapter_found() {
|
||||
let (status, cmd, path) = classify_provider(
|
||||
Some(("goose", PathBuf::from("/usr/local/bin/goose"))),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(cmd.as_deref(), Some("goose"));
|
||||
assert_eq!(path.as_deref(), Some("/usr/local/bin/goose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_adapter_missing_when_cli_present() {
|
||||
let (status, cmd, path) = classify_provider(None, Some("claude"), true);
|
||||
assert_eq!(status, AcpAvailabilityStatus::AdapterMissing);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_not_installed_when_nothing_found() {
|
||||
let (status, cmd, path) = classify_provider(None, Some("claude"), false);
|
||||
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_not_installed_when_no_underlying_cli() {
|
||||
let (status, cmd, path) = classify_provider(None, None, false);
|
||||
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
|
||||
let (status, cmd, path) = classify_provider(
|
||||
Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))),
|
||||
Some("codex"),
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, AcpAvailabilityStatus::CliMissing);
|
||||
assert_eq!(cmd.as_deref(), Some("codex-acp"));
|
||||
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,15 +275,48 @@ pub struct ManagedAgentLogResponse {
|
||||
pub log_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AcpAvailabilityStatus {
|
||||
Available,
|
||||
AdapterMissing,
|
||||
CliMissing,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AcpProviderInfo {
|
||||
pub struct AcpProviderCatalogEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
pub binary_path: String,
|
||||
pub avatar_url: String,
|
||||
pub availability: AcpAvailabilityStatus,
|
||||
pub command: Option<String>,
|
||||
pub binary_path: Option<String>,
|
||||
pub default_args: Vec<String>,
|
||||
/// MCP server binary override. `None` means use the default (`sprout-mcp-server`).
|
||||
pub mcp_command: Option<String>,
|
||||
pub install_hint: String,
|
||||
pub install_instructions_url: String,
|
||||
/// true when at least one automated install step is available
|
||||
pub can_auto_install: bool,
|
||||
pub underlying_cli_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a single install step (CLI or adapter).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InstallStepResult {
|
||||
pub step: String,
|
||||
pub command: String,
|
||||
pub success: bool,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Aggregate result of installing a runtime (may include CLI + adapter steps).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InstallRuntimeResult {
|
||||
pub success: bool,
|
||||
pub steps: Vec<InstallStepResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
discoverBackendProviders,
|
||||
discoverManagedAgentPrereqs,
|
||||
getManagedAgentLog,
|
||||
installAcpRuntime,
|
||||
listManagedAgents,
|
||||
listRelayAgents,
|
||||
startManagedAgent,
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
updateTeam,
|
||||
} from "@/shared/api/tauriTeams";
|
||||
import type {
|
||||
AcpProvider,
|
||||
AgentPersona,
|
||||
AgentTeam,
|
||||
CreateManagedAgentInput,
|
||||
@@ -105,6 +108,28 @@ export function useAcpProvidersQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAvailableAcpProviders() {
|
||||
const query = useAcpProvidersQuery();
|
||||
const available = React.useMemo(
|
||||
() =>
|
||||
(query.data ?? []).filter(
|
||||
(p): p is AcpProvider => p.availability === "available",
|
||||
),
|
||||
[query.data],
|
||||
);
|
||||
return { ...query, data: available };
|
||||
}
|
||||
|
||||
export function useInstallAcpRuntimeMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (providerId: string) => installAcpRuntime(providerId),
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: acpProvidersQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackendProvidersQuery() {
|
||||
return useQuery({
|
||||
queryKey: backendProvidersQueryKey,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAvailableAcpProviders,
|
||||
useCreateChannelManagedAgentsMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
|
||||
@@ -50,7 +50,7 @@ export function AddTeamToChannelDialog({
|
||||
onDeployed,
|
||||
}: AddTeamToChannelDialogProps) {
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providersQuery = useAvailableAcpProviders();
|
||||
const [channelId, setChannelId] = React.useState("");
|
||||
const [role, setRole] = React.useState<Exclude<ChannelRole, "owner">>("bot");
|
||||
const deployMutation = useCreateChannelManagedAgentsMutation(
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAvailableAcpProviders,
|
||||
useBackendProvidersQuery,
|
||||
useCreateManagedAgentMutation,
|
||||
useManagedAgentPrereqsQuery,
|
||||
@@ -48,7 +49,8 @@ export function CreateAgentDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const createMutation = useCreateManagedAgentMutation();
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providersQuery = useAvailableAcpProviders();
|
||||
const allProvidersQuery = useAcpProvidersQuery();
|
||||
const backendProvidersQuery = useBackendProvidersQuery();
|
||||
const { lastProviderId, setLastProvider } = useLastRuntimeProvider();
|
||||
const [acpCommand, setAcpCommand] = React.useState("sprout-acp");
|
||||
@@ -85,6 +87,10 @@ export function CreateAgentDialog({
|
||||
const [probeError, setProbeError] = React.useState<string | null>(null);
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const allProviders = allProvidersQuery.data ?? [];
|
||||
const unavailableCount = allProviders.filter(
|
||||
(p) => p.availability !== "available",
|
||||
).length;
|
||||
const backendProviders = backendProvidersQuery.data ?? [];
|
||||
const prereqs = prereqsQuery.data ?? null;
|
||||
const selectedProvider = React.useMemo(
|
||||
@@ -443,6 +449,7 @@ export function CreateAgentDialog({
|
||||
providersLoading={providersQuery.isLoading}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProviderId={selectedProviderId}
|
||||
unavailableCount={unavailableCount}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -40,12 +40,14 @@ export function CreateAgentRuntimeProviderField({
|
||||
providersLoading,
|
||||
selectedProvider,
|
||||
selectedProviderId,
|
||||
unavailableCount,
|
||||
onProviderChange,
|
||||
}: {
|
||||
providers: AcpProvider[];
|
||||
providersLoading: boolean;
|
||||
selectedProvider: AcpProvider | null;
|
||||
selectedProviderId: string;
|
||||
unavailableCount: number;
|
||||
onProviderChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -86,6 +88,14 @@ export function CreateAgentRuntimeProviderField({
|
||||
command in Advanced setup.
|
||||
</p>
|
||||
)}
|
||||
{unavailableCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unavailableCount} additional{" "}
|
||||
{unavailableCount === 1 ? "runtime" : "runtimes"} available to
|
||||
install. Visit Settings > Doctor to set{" "}
|
||||
{unavailableCount === 1 ? "it" : "them"} up.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { RefreshCw, Upload } from "lucide-react";
|
||||
|
||||
import type {
|
||||
AcpProvider,
|
||||
AcpProviderCatalogEntry,
|
||||
CreatePersonaInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
@@ -35,7 +35,7 @@ type PersonaDialogProps = {
|
||||
error: Error | null;
|
||||
isPending: boolean;
|
||||
isImportPending?: boolean;
|
||||
providers: AcpProvider[];
|
||||
providers: AcpProviderCatalogEntry[];
|
||||
providersLoading?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
|
||||
@@ -266,6 +266,19 @@ export function PersonaDialog({
|
||||
importErrorMessage,
|
||||
});
|
||||
|
||||
const selectedProvider = providers.find((p) => p.id === provider);
|
||||
const providerWarning =
|
||||
selectedProvider && selectedProvider.availability !== "available" ? (
|
||||
<p className="text-xs text-warning">
|
||||
{selectedProvider.availability === "adapter_missing"
|
||||
? `${selectedProvider.label} CLI is installed but the ACP adapter is missing.`
|
||||
: selectedProvider.availability === "cli_missing"
|
||||
? `${selectedProvider.label} ACP adapter is installed but the CLI is missing.`
|
||||
: `${selectedProvider.label} is not installed.`}{" "}
|
||||
Visit Settings > Doctor to set it up.
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden p-0">
|
||||
@@ -354,13 +367,22 @@ export function PersonaDialog({
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
{p.availability === "adapter_missing"
|
||||
? " (adapter missing)"
|
||||
: p.availability === "cli_missing"
|
||||
? " (CLI missing)"
|
||||
: p.availability === "not_installed"
|
||||
? " (not installed)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. When deploying this persona, the selected runtime will
|
||||
be pre-selected. Falls back to the default if unavailable.
|
||||
be pre-selected. Unavailable runtimes can be installed from
|
||||
Settings > Doctor.
|
||||
</p>
|
||||
{providerWarning}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type CreateChannelManagedAgentInput,
|
||||
} from "@/features/agents/channelAgents";
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAvailableAcpProviders,
|
||||
usePersonasQuery,
|
||||
useTeamsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
@@ -29,7 +29,7 @@ function toManagedBackend(
|
||||
export function useApplyTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
const channelTemplatesQuery = useChannelTemplatesQuery();
|
||||
const acpProvidersQuery = useAcpProvidersQuery();
|
||||
const acpProvidersQuery = useAvailableAcpProviders();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const teamsQuery = useTeamsQuery();
|
||||
const { lastProviderId } = useLastRuntimeProvider();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useHuddle } from "@/features/huddle";
|
||||
import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator";
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAvailableAcpProviders,
|
||||
useBackendProvidersQuery,
|
||||
useManagedAgentsQuery,
|
||||
useRelayAgentsQuery,
|
||||
@@ -32,7 +32,7 @@ export function ChannelMembersBar({
|
||||
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
|
||||
const queryClient = useQueryClient();
|
||||
const membersQuery = useChannelMembersQuery(channel.id);
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providersQuery = useAvailableAcpProviders();
|
||||
const backendProvidersQuery = useBackendProvidersQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAvailableAcpProviders,
|
||||
useCreateChannelManagedAgentMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider";
|
||||
@@ -17,7 +17,7 @@ type QuickBotDropState = {
|
||||
*/
|
||||
export function useQuickBotDrop(channelId: string | null) {
|
||||
const createMutation = useCreateChannelManagedAgentMutation(channelId);
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providersQuery = useAvailableAcpProviders();
|
||||
const [state, setState] = React.useState<QuickBotDropState>({
|
||||
pending: false,
|
||||
error: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TerminalSquare } from "lucide-react";
|
||||
|
||||
import { useAcpProvidersQuery } from "@/features/agents/hooks";
|
||||
import { useAvailableAcpProviders } from "@/features/agents/hooks";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { SetupStepActions, SetupStepState } from "./types";
|
||||
@@ -15,7 +15,7 @@ type SetupStepContentProps = {
|
||||
};
|
||||
|
||||
function useSetupStepState(): SetupStepState {
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providersQuery = useAvailableAcpProviders();
|
||||
const items = providersQuery.data ?? [];
|
||||
const isChecking = providersQuery.isLoading;
|
||||
const errorMessage =
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useAvailableAcpProviders,
|
||||
usePersonasQuery,
|
||||
useTeamsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
@@ -295,7 +295,7 @@ function TemplateFormDialog({
|
||||
const updateMutation = useUpdateChannelTemplateMutation();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const teamsQuery = useTeamsQuery();
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providersQuery = useAvailableAcpProviders();
|
||||
const providers = providersQuery.data ?? [];
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
|
||||
@@ -1,64 +1,221 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Download,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
import { useAcpProvidersQuery } from "@/features/agents/hooks";
|
||||
import {
|
||||
useAcpProvidersQuery,
|
||||
useInstallAcpRuntimeMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
|
||||
import type { AcpProviderCatalogEntry } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
function StatusIcon({ available }: { available: boolean }) {
|
||||
return available ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-status-added" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
function StatusIcon({
|
||||
availability,
|
||||
}: {
|
||||
availability: AcpProviderCatalogEntry["availability"];
|
||||
}) {
|
||||
switch (availability) {
|
||||
case "available":
|
||||
return <CheckCircle2 className="h-4 w-4 text-status-added" />;
|
||||
case "adapter_missing":
|
||||
return <AlertTriangle className="h-4 w-4 text-warning" />;
|
||||
case "cli_missing":
|
||||
return <AlertTriangle className="h-4 w-4 text-warning" />;
|
||||
case "not_installed":
|
||||
return <Circle className="h-4 w-4 text-muted-foreground/50" />;
|
||||
}
|
||||
}
|
||||
|
||||
function InstallActions({
|
||||
isInstalling,
|
||||
onInstall,
|
||||
provider,
|
||||
}: {
|
||||
isInstalling: boolean;
|
||||
onInstall: () => void;
|
||||
provider: AcpProviderCatalogEntry;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{provider.canAutoInstall ? (
|
||||
<Button
|
||||
disabled={isInstalling}
|
||||
onClick={onInstall}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isInstalling ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
) : null}
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
onClick={() => void openUrl(provider.installInstructionsUrl)}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View instructions
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
command,
|
||||
defaultArgs,
|
||||
label,
|
||||
providerId,
|
||||
resolvedPath,
|
||||
installError,
|
||||
installSuccess,
|
||||
isInstalling,
|
||||
onInstall,
|
||||
provider,
|
||||
}: {
|
||||
command: string;
|
||||
defaultArgs: string[];
|
||||
label: string;
|
||||
providerId: string;
|
||||
resolvedPath: string;
|
||||
installError: string | null;
|
||||
installSuccess: boolean;
|
||||
isInstalling: boolean;
|
||||
onInstall: () => void;
|
||||
provider: AcpProviderCatalogEntry;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 px-4 py-3"
|
||||
data-testid={`doctor-provider-${providerId}`}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-xl border px-4 py-3",
|
||||
provider.availability === "available"
|
||||
? "border-border/70 bg-background/80"
|
||||
: provider.availability === "adapter_missing" ||
|
||||
provider.availability === "cli_missing"
|
||||
? "border-amber-500/30 bg-amber-500/5"
|
||||
: "border-border/50 bg-muted/30",
|
||||
)}
|
||||
data-testid={`doctor-provider-${provider.id}`}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<StatusIcon available={true} />
|
||||
<StatusIcon availability={provider.availability} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold tracking-tight">{label}</p>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
|
||||
{command}
|
||||
</code>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
{provider.label}
|
||||
</p>
|
||||
{provider.command ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
|
||||
{provider.command}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Available via {describeResolvedCommand(command, resolvedPath)}.
|
||||
</p>
|
||||
{defaultArgs.length > 0 ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Default args:{" "}
|
||||
<code className="font-mono">{defaultArgs.join(", ")}</code>
|
||||
|
||||
{provider.availability === "available" &&
|
||||
provider.command &&
|
||||
provider.binaryPath ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Available via{" "}
|
||||
{describeResolvedCommand(provider.command, provider.binaryPath)}.
|
||||
</p>
|
||||
{provider.defaultArgs.length > 0 ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Default args:{" "}
|
||||
<code className="font-mono">
|
||||
{provider.defaultArgs.join(", ")}
|
||||
</code>
|
||||
</p>
|
||||
) : null}
|
||||
{provider.underlyingCliPath &&
|
||||
provider.underlyingCliPath !== provider.binaryPath ? (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<p className="break-all font-mono text-[11px] text-muted-foreground/80">
|
||||
<span className="text-muted-foreground">CLI:</span>{" "}
|
||||
{provider.underlyingCliPath}
|
||||
</p>
|
||||
<p className="break-all font-mono text-[11px] text-muted-foreground/80">
|
||||
<span className="text-muted-foreground">ACP adapter:</span>{" "}
|
||||
{provider.binaryPath}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
|
||||
{provider.binaryPath}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">
|
||||
ACP support built-in — no separate adapter needed.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : provider.availability === "adapter_missing" ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
CLI detected at{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">
|
||||
{provider.underlyingCliPath ?? "unknown path"}
|
||||
</code>{" "}
|
||||
but ACP adapter not found.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{provider.installHint}
|
||||
</p>
|
||||
<InstallActions
|
||||
isInstalling={isInstalling}
|
||||
onInstall={onInstall}
|
||||
provider={provider}
|
||||
/>
|
||||
</>
|
||||
) : provider.availability === "cli_missing" ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
ACP adapter found at{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">
|
||||
{provider.binaryPath ?? "unknown path"}
|
||||
</code>{" "}
|
||||
but the {provider.label} CLI is not installed.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{provider.installHint}
|
||||
</p>
|
||||
<InstallActions
|
||||
isInstalling={isInstalling}
|
||||
onInstall={onInstall}
|
||||
provider={provider}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Not installed</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{provider.installHint}
|
||||
</p>
|
||||
<InstallActions
|
||||
isInstalling={isInstalling}
|
||||
onInstall={onInstall}
|
||||
provider={provider}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{installSuccess && provider.availability !== "available" ? (
|
||||
<p className="mt-2 rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-1.5 text-xs text-green-700 dark:text-green-400">
|
||||
Installed successfully!
|
||||
</p>
|
||||
) : null}
|
||||
{installError ? (
|
||||
<p className="mt-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||
{installError}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
|
||||
{resolvedPath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -68,6 +225,47 @@ export function DoctorSettingsPanel() {
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const providers = providersQuery.data ?? [];
|
||||
const isRefreshing = providersQuery.isFetching;
|
||||
const installMutation = useInstallAcpRuntimeMutation();
|
||||
const [installResults, setInstallResults] = React.useState<
|
||||
Record<string, { success: boolean; error: string | null }>
|
||||
>({});
|
||||
|
||||
function handleInstall(providerId: string) {
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { success: false, error: null },
|
||||
}));
|
||||
installMutation.mutate(providerId, {
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { success: true, error: null },
|
||||
}));
|
||||
} else {
|
||||
const lastStep = result.steps[result.steps.length - 1];
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[providerId]: {
|
||||
success: false,
|
||||
error: lastStep
|
||||
? `Step "${lastStep.step}" failed: ${lastStep.stderr || lastStep.stdout || "unknown error"}`
|
||||
: "Install failed with no output.",
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[providerId]: {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Install failed.",
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5" data-testid="settings-doctor">
|
||||
@@ -86,6 +284,7 @@ export function DoctorSettingsPanel() {
|
||||
className="shrink-0"
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
setInstallResults({});
|
||||
void providersQuery.refetch();
|
||||
}}
|
||||
size="sm"
|
||||
@@ -101,31 +300,35 @@ export function DoctorSettingsPanel() {
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">ACP runtimes</h3>
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
Agent CLIs and ACP runtimes
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Installed runtimes that the desktop app can offer in Create agent.
|
||||
Installation status of supported agent CLIs and their ACP runtimes.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{providersQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Looking for installed ACP runtimes...
|
||||
Looking for ACP runtimes...
|
||||
</p>
|
||||
) : providers.length > 0 ? (
|
||||
providers.map((provider) => (
|
||||
<ProviderRow
|
||||
command={provider.command}
|
||||
defaultArgs={provider.defaultArgs}
|
||||
installError={installResults[provider.id]?.error ?? null}
|
||||
installSuccess={installResults[provider.id]?.success ?? false}
|
||||
isInstalling={
|
||||
installMutation.isPending &&
|
||||
installMutation.variables === provider.id
|
||||
}
|
||||
key={provider.id}
|
||||
label={provider.label}
|
||||
providerId={provider.id}
|
||||
resolvedPath={provider.binaryPath}
|
||||
onInstall={() => handleInstall(provider.id)}
|
||||
provider={provider}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
|
||||
No known ACP runtime was detected on your PATH yet. You can
|
||||
still use a custom command in Create agent.
|
||||
No known ACP runtimes found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,10 @@ import type {
|
||||
CreateManagedAgentInput,
|
||||
AgentModelsResponse,
|
||||
UpdateManagedAgentInput,
|
||||
AcpProvider,
|
||||
AcpAvailabilityStatus,
|
||||
AcpProviderCatalogEntry,
|
||||
CommandAvailability,
|
||||
InstallRuntimeResult,
|
||||
OpenDmInput,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@@ -245,13 +247,33 @@ type RawManagedAgentLog = {
|
||||
log_path: string;
|
||||
};
|
||||
|
||||
type RawAcpProvider = {
|
||||
export type RawAcpProviderCatalogEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
binary_path: string;
|
||||
avatar_url: string;
|
||||
availability: AcpAvailabilityStatus;
|
||||
command: string | null;
|
||||
binary_path: string | null;
|
||||
default_args: string[];
|
||||
mcp_command: string | null;
|
||||
install_hint: string;
|
||||
install_instructions_url: string;
|
||||
can_auto_install: boolean;
|
||||
underlying_cli_path: string | null;
|
||||
};
|
||||
|
||||
export type RawInstallStepResult = {
|
||||
step: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number | null;
|
||||
};
|
||||
|
||||
export type RawInstallRuntimeResult = {
|
||||
success: boolean;
|
||||
steps: RawInstallStepResult[];
|
||||
};
|
||||
|
||||
type RawCommandAvailability = {
|
||||
@@ -850,14 +872,38 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider {
|
||||
function fromRawAcpProviderCatalogEntry(
|
||||
entry: RawAcpProviderCatalogEntry,
|
||||
): AcpProviderCatalogEntry {
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
command: provider.command,
|
||||
binaryPath: provider.binary_path,
|
||||
defaultArgs: provider.default_args,
|
||||
mcpCommand: provider.mcp_command,
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
avatarUrl: entry.avatar_url,
|
||||
availability: entry.availability,
|
||||
command: entry.command,
|
||||
binaryPath: entry.binary_path,
|
||||
defaultArgs: entry.default_args,
|
||||
mcpCommand: entry.mcp_command,
|
||||
installHint: entry.install_hint,
|
||||
installInstructionsUrl: entry.install_instructions_url,
|
||||
canAutoInstall: entry.can_auto_install,
|
||||
underlyingCliPath: entry.underlying_cli_path,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawInstallRuntimeResult(
|
||||
raw: RawInstallRuntimeResult,
|
||||
): InstallRuntimeResult {
|
||||
return {
|
||||
success: raw.success,
|
||||
steps: raw.steps.map((step) => ({
|
||||
step: step.step,
|
||||
command: step.command,
|
||||
success: step.success,
|
||||
stdout: step.stdout,
|
||||
stderr: step.stderr,
|
||||
exitCode: step.exit_code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1013,10 +1059,22 @@ export async function getManagedAgentLog(pubkey: string, lineCount?: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
|
||||
return (await invokeTauri<RawAcpProvider[]>("discover_acp_providers")).map(
|
||||
fromRawAcpProvider,
|
||||
export async function discoverAcpProviders(): Promise<
|
||||
AcpProviderCatalogEntry[]
|
||||
> {
|
||||
return (
|
||||
await invokeTauri<RawAcpProviderCatalogEntry[]>("discover_acp_providers")
|
||||
).map(fromRawAcpProviderCatalogEntry);
|
||||
}
|
||||
|
||||
export async function installAcpRuntime(
|
||||
providerId: string,
|
||||
): Promise<InstallRuntimeResult> {
|
||||
const raw = await invokeTauri<RawInstallRuntimeResult>(
|
||||
"install_acp_runtime",
|
||||
{ providerId },
|
||||
);
|
||||
return fromRawInstallRuntimeResult(raw);
|
||||
}
|
||||
|
||||
export async function discoverManagedAgentPrereqs(input: {
|
||||
|
||||
@@ -366,14 +366,46 @@ export type CancelManagedAgentTurnResult = {
|
||||
status: "sent" | "no_active_turn";
|
||||
};
|
||||
|
||||
export type AcpProvider = {
|
||||
export type AcpAvailabilityStatus =
|
||||
| "available"
|
||||
| "adapter_missing"
|
||||
| "cli_missing"
|
||||
| "not_installed";
|
||||
|
||||
export type AcpProviderCatalogEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
availability: AcpAvailabilityStatus;
|
||||
command: string | null;
|
||||
binaryPath: string | null;
|
||||
defaultArgs: string[];
|
||||
mcpCommand: string | null;
|
||||
installHint: string;
|
||||
installInstructionsUrl: string;
|
||||
canAutoInstall: boolean;
|
||||
underlyingCliPath: string | null;
|
||||
};
|
||||
|
||||
/** An AcpProviderCatalogEntry that is confirmed available — command and binaryPath are non-null. */
|
||||
export type AcpProvider = AcpProviderCatalogEntry & {
|
||||
availability: "available";
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
/** MCP server binary override, or `null` for the default (`sprout-mcp-server`). */
|
||||
mcpCommand: string | null;
|
||||
};
|
||||
|
||||
export type InstallStepResult = {
|
||||
step: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
};
|
||||
|
||||
export type InstallRuntimeResult = {
|
||||
success: boolean;
|
||||
steps: InstallStepResult[];
|
||||
};
|
||||
|
||||
export type CommandAvailability = {
|
||||
|
||||
@@ -4,6 +4,10 @@ import { finalizeEvent } from "nostr-tools/pure";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import type {
|
||||
RawAcpProviderCatalogEntry,
|
||||
RawInstallRuntimeResult,
|
||||
} from "@/shared/api/tauri";
|
||||
|
||||
type TestIdentity = {
|
||||
privateKey: string;
|
||||
@@ -11,15 +15,6 @@ type TestIdentity = {
|
||||
username: string;
|
||||
};
|
||||
|
||||
type MockAcpProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
mcpCommand?: string | null;
|
||||
};
|
||||
|
||||
type MockCommandAvailability = {
|
||||
available?: boolean;
|
||||
command?: string;
|
||||
@@ -29,7 +24,8 @@ type MockCommandAvailability = {
|
||||
type E2eConfig = {
|
||||
mode?: "mock" | "relay";
|
||||
mock?: {
|
||||
acpProviders?: MockAcpProvider[];
|
||||
acpProvidersCatalog?: RawAcpProviderCatalogEntry[];
|
||||
installAcpRuntimeResult?: RawInstallRuntimeResult;
|
||||
managedAgentPrereqs?: {
|
||||
acp?: MockCommandAvailability;
|
||||
mcp?: MockCommandAvailability;
|
||||
@@ -318,15 +314,6 @@ type RawManagedAgentLog = {
|
||||
log_path: string;
|
||||
};
|
||||
|
||||
type RawAcpProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
binary_path: string;
|
||||
default_args: string[];
|
||||
mcp_command: string | null;
|
||||
};
|
||||
|
||||
type RawCommandAvailability = {
|
||||
command: string;
|
||||
resolved_path: string | null;
|
||||
@@ -3468,39 +3455,98 @@ async function handleListRelayAgents(): Promise<RawRelayAgent[]> {
|
||||
|
||||
async function handleDiscoverAcpProviders(
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawAcpProvider[]> {
|
||||
const configuredProviders = config?.mock?.acpProviders;
|
||||
if (configuredProviders) {
|
||||
return configuredProviders.map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
command: provider.command,
|
||||
binary_path: provider.binaryPath,
|
||||
default_args: [...provider.defaultArgs],
|
||||
mcp_command: provider.mcpCommand ?? null,
|
||||
}));
|
||||
): Promise<RawAcpProviderCatalogEntry[]> {
|
||||
const configured = config?.mock?.acpProvidersCatalog;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "goose",
|
||||
label: "Goose",
|
||||
avatar_url: "",
|
||||
availability: "available",
|
||||
command: "goose",
|
||||
binary_path: "/usr/local/bin/goose",
|
||||
default_args: ["acp"],
|
||||
mcp_command: null,
|
||||
install_hint: "Install Goose via the official install script.",
|
||||
install_instructions_url: "https://block.github.io/goose/",
|
||||
can_auto_install: true,
|
||||
underlying_cli_path: null,
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
avatar_url: "",
|
||||
availability: "adapter_missing",
|
||||
command: null,
|
||||
binary_path: null,
|
||||
default_args: [],
|
||||
mcp_command: null,
|
||||
install_hint: "Install the Claude Code ACP adapter via npm.",
|
||||
install_instructions_url:
|
||||
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
|
||||
can_auto_install: true,
|
||||
underlying_cli_path: "/usr/local/bin/claude",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
command: "codex-acp",
|
||||
binary_path: "/usr/local/bin/codex-acp",
|
||||
avatar_url: "",
|
||||
availability: "not_installed",
|
||||
command: null,
|
||||
binary_path: null,
|
||||
default_args: [],
|
||||
mcp_command: null,
|
||||
install_hint:
|
||||
"The codex-acp adapter must be built from source. See the GitHub repo.",
|
||||
install_instructions_url: "https://github.com/openai/codex",
|
||||
can_auto_install: false,
|
||||
underlying_cli_path: null,
|
||||
},
|
||||
{
|
||||
id: "sprout-agent",
|
||||
label: "Sprout Agent",
|
||||
avatar_url: "",
|
||||
availability: "available",
|
||||
command: "sprout-agent",
|
||||
binary_path: "/usr/local/bin/sprout-agent",
|
||||
default_args: [],
|
||||
mcp_command: "sprout-dev-mcp",
|
||||
install_hint: "Ships with the Sprout desktop app.",
|
||||
install_instructions_url: "https://github.com/block/sprout",
|
||||
can_auto_install: false,
|
||||
underlying_cli_path: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function handleInstallAcpRuntime(
|
||||
args: {
|
||||
providerId?: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawInstallRuntimeResult> {
|
||||
const configured = config?.mock?.installAcpRuntimeResult;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
steps: [
|
||||
{
|
||||
step: "adapter",
|
||||
command: `mock install ${args.providerId ?? "unknown"}`,
|
||||
success: true,
|
||||
stdout: "mock: installed successfully",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDiscoverManagedAgentPrereqs(
|
||||
args: {
|
||||
input?: {
|
||||
@@ -4673,6 +4719,11 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return getRelayHttpUrl(activeConfig);
|
||||
case "discover_acp_providers":
|
||||
return handleDiscoverAcpProviders(activeConfig);
|
||||
case "install_acp_runtime":
|
||||
return handleInstallAcpRuntime(
|
||||
payload as { providerId?: string },
|
||||
activeConfig,
|
||||
);
|
||||
case "discover_backend_providers":
|
||||
return [];
|
||||
case "probe_backend_provider":
|
||||
|
||||
@@ -197,7 +197,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
acpProviders: [],
|
||||
acpProvidersCatalog: [],
|
||||
},
|
||||
{ skipOnboardingSeed: true },
|
||||
);
|
||||
|
||||
@@ -35,14 +35,6 @@ export const TEST_IDENTITIES = {
|
||||
|
||||
type BridgeMode = "mock" | "relay";
|
||||
|
||||
type MockAcpProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
};
|
||||
|
||||
type MockCommandAvailability = {
|
||||
available?: boolean;
|
||||
command?: string;
|
||||
@@ -50,7 +42,7 @@ type MockCommandAvailability = {
|
||||
};
|
||||
|
||||
type MockBridgeOptions = {
|
||||
acpProviders?: MockAcpProvider[];
|
||||
acpProvidersCatalog?: Record<string, unknown>[];
|
||||
managedAgentPrereqs?: {
|
||||
acp?: MockCommandAvailability;
|
||||
mcp?: MockCommandAvailability;
|
||||
|
||||
Reference in New Issue
Block a user