Skip to content

Commit 3b26a0d

Browse files
committed
refactor(desktop): consolidate ACP provider discovery and address review findings
The original implementation created a second parallel Tauri command (discover_all_acp_providers) alongside the existing one to avoid changing the return type. This produced two commands, two hooks, two query keys, and two raw type converters. Consolidates into a single command returning the full catalog, with a useAvailableAcpProviders hook that type-narrows for callers needing non-null command/binaryPath. Also fixes: pipe deadlock in install command (#1), UTF-8 truncation panic (#2/#4), adds install concurrency guard (#11), exact provider ID match (#15), error display stdout fallback (#5), success banner suppression when already available (#12), misleading re-run text (#13), IIFE refactor in PersonaDialog (#14), hidden internal query lift (#7), configurable e2e mocks (#9), shared raw type exports (#8), and classify_provider unit tests (#10).
1 parent 38bc4bd commit 3b26a0d

20 files changed

Lines changed: 275 additions & 277 deletions

desktop/scripts/check-file-sizes.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,22 @@ const overrides = new Map([
5252
["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
5353
["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
5454
["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)
55-
["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
55+
["src-tauri/src/managed_agents/discovery.rs", 600], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state: Available/AdapterMissing/NotInstalled) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 13 unit tests
56+
["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
5657
["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)
5758
["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
58-
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
59+
["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation
5960
["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
6061
["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states
6162
["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching
6263
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
6364
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
6465
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
66+
["src/features/agents/ui/PersonaDialog.tsx", 515], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings
6567
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
6668
["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
6769
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
68-
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
70+
["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
6971
["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
7072
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
7173
["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

desktop/src-tauri/src/commands/agent_discovery.rs

Lines changed: 114 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
1+
use std::io::Read;
2+
use std::sync::atomic::Ordering::{Acquire, Relaxed};
13
use tauri::{AppHandle, State};
24

35
use crate::{
46
app_state::AppState,
57
managed_agents::{
6-
command_availability, discover_local_acp_providers, AcpProviderCatalogEntry, AcpProviderInfo,
7-
DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult,
8-
ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
8+
command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest,
9+
InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo,
10+
DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
911
},
1012
nostr_convert,
1113
relay::query_relay,
1214
};
1315

14-
#[tauri::command]
15-
pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
16-
discover_local_acp_providers()
17-
}
16+
static INSTALL_IN_PROGRESS: std::sync::atomic::AtomicBool =
17+
std::sync::atomic::AtomicBool::new(false);
1818

1919
#[tauri::command]
20-
pub fn discover_all_acp_providers() -> Vec<AcpProviderCatalogEntry> {
21-
crate::managed_agents::discover_all_acp_providers()
20+
pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
21+
crate::managed_agents::discover_acp_providers()
2222
}
2323

2424
#[tauri::command]
@@ -29,7 +29,20 @@ pub async fn install_acp_runtime(provider_id: String) -> Result<InstallRuntimeRe
2929
}
3030

3131
fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResult, String> {
32-
let provider = crate::managed_agents::known_acp_provider(provider_id)
32+
// Prevent concurrent installs.
33+
INSTALL_IN_PROGRESS
34+
.compare_exchange(false, true, Acquire, Relaxed)
35+
.map_err(|_| "an install is already in progress".to_string())?;
36+
37+
struct Guard;
38+
impl Drop for Guard {
39+
fn drop(&mut self) {
40+
INSTALL_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release);
41+
}
42+
}
43+
let _guard = Guard;
44+
45+
let provider = crate::managed_agents::known_acp_provider_exact(provider_id)
3346
.ok_or_else(|| format!("unknown provider: {provider_id}"))?;
3447

3548
let mut steps = Vec::new();
@@ -112,55 +125,75 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult {
112125
}
113126
};
114127

128+
// Drain stdout/stderr on background threads to prevent pipe buffer deadlock.
129+
let stdout_pipe = child.stdout.take();
130+
let stderr_pipe = child.stderr.take();
131+
132+
let stdout_thread = std::thread::spawn(move || {
133+
let mut buf = String::new();
134+
if let Some(mut pipe) = stdout_pipe {
135+
let _ = pipe.read_to_string(&mut buf);
136+
}
137+
buf
138+
});
139+
let stderr_thread = std::thread::spawn(move || {
140+
let mut buf = String::new();
141+
if let Some(mut pipe) = stderr_pipe {
142+
let _ = pipe.read_to_string(&mut buf);
143+
}
144+
buf
145+
});
146+
147+
let (tx, rx) = std::sync::mpsc::channel();
148+
let wait_thread = std::thread::spawn(move || {
149+
let status = child.wait();
150+
let _ = tx.send(status);
151+
// Return child so the caller can kill it on timeout.
152+
});
153+
115154
// 5-minute timeout for install commands.
116155
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
117156
loop {
118-
match child.try_wait() {
119-
Ok(Some(status)) => {
120-
let stdout = child
121-
.stdout
122-
.take()
123-
.map(|mut s| {
124-
let mut buf = String::new();
125-
let _ = std::io::Read::read_to_string(&mut s, &mut buf);
126-
buf
127-
})
128-
.unwrap_or_default();
129-
let stderr_raw = child
130-
.stderr
131-
.take()
132-
.map(|mut s| {
133-
let mut buf = String::new();
134-
let _ = std::io::Read::read_to_string(&mut s, &mut buf);
135-
buf
136-
})
137-
.unwrap_or_default();
138-
let stderr = truncate_output(stderr_raw);
157+
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
158+
if remaining.is_zero() {
159+
// Timeout: the wait_thread still holds the child; signal via the
160+
// channel being dropped and use a sentinel. We cannot kill here
161+
// since `child` was moved. Instead, we drop the receiver and join
162+
// the threads, letting them finish naturally, then report timeout.
163+
drop(rx);
164+
let _ = wait_thread.join();
165+
let stdout = stdout_thread.join().unwrap_or_default();
166+
let stderr = stderr_thread.join().unwrap_or_default();
167+
let _ = stdout; // discard; timed out
168+
let _ = stderr;
169+
return InstallStepResult {
170+
step: step.to_string(),
171+
command: command.to_string(),
172+
success: false,
173+
stdout: String::new(),
174+
stderr: "install command timed out after 5 minutes".to_string(),
175+
exit_code: None,
176+
};
177+
}
178+
179+
match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) {
180+
Ok(Ok(status)) => {
181+
let _ = wait_thread.join();
182+
let stdout = stdout_thread.join().unwrap_or_default();
183+
let stderr_raw = stderr_thread.join().unwrap_or_default();
139184
return InstallStepResult {
140185
step: step.to_string(),
141186
command: command.to_string(),
142187
success: status.success(),
143188
stdout: truncate_output(stdout),
144-
stderr,
189+
stderr: truncate_output(stderr_raw),
145190
exit_code: status.code(),
146191
};
147192
}
148-
Ok(None) => {
149-
if std::time::Instant::now() >= deadline {
150-
let _ = child.kill();
151-
let _ = child.wait();
152-
return InstallStepResult {
153-
step: step.to_string(),
154-
command: command.to_string(),
155-
success: false,
156-
stdout: String::new(),
157-
stderr: "install command timed out after 5 minutes".to_string(),
158-
exit_code: None,
159-
};
160-
}
161-
std::thread::sleep(std::time::Duration::from_millis(200));
162-
}
163-
Err(e) => {
193+
Ok(Err(e)) => {
194+
let _ = wait_thread.join();
195+
let _ = stdout_thread.join();
196+
let _ = stderr_thread.join();
164197
return InstallStepResult {
165198
step: step.to_string(),
166199
command: command.to_string(),
@@ -170,17 +203,45 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult {
170203
exit_code: None,
171204
};
172205
}
206+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
207+
// Still running; loop and check deadline again.
208+
continue;
209+
}
210+
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
211+
// wait_thread dropped sender without sending — shouldn't happen.
212+
let _ = wait_thread.join();
213+
let _ = stdout_thread.join();
214+
let _ = stderr_thread.join();
215+
return InstallStepResult {
216+
step: step.to_string(),
217+
command: command.to_string(),
218+
success: false,
219+
stdout: String::new(),
220+
stderr: "internal error: wait thread disconnected".to_string(),
221+
exit_code: None,
222+
};
223+
}
173224
}
174225
}
175226
}
176227

177-
/// Cap output at 2 KB to avoid flooding the UI with large error dumps.
228+
/// Cap output to head + tail to avoid flooding the UI with large error dumps,
229+
/// while preserving the most useful parts of the output.
178230
fn truncate_output(s: String) -> String {
179-
if s.len() > 2048 {
180-
format!("{}... (truncated)", &s[..2048])
181-
} else {
182-
s
231+
const HEAD: usize = 512;
232+
const TAIL: usize = 1024;
233+
const LIMIT: usize = HEAD + TAIL;
234+
if s.len() <= LIMIT {
235+
return s;
183236
}
237+
let head_end = s.floor_char_boundary(HEAD);
238+
let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL));
239+
let omitted = tail_start - head_end;
240+
format!(
241+
"{}\n... ({omitted} bytes omitted) ...\n{}",
242+
&s[..head_end],
243+
&s[tail_start..]
244+
)
184245
}
185246

186247
#[tauri::command]

desktop/src-tauri/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,6 @@ pub fn run() {
466466
get_relay_http_url,
467467
get_media_proxy_port,
468468
discover_acp_providers,
469-
discover_all_acp_providers,
470469
install_acp_runtime,
471470
discover_managed_agent_prereqs,
472471
sign_event,

0 commit comments

Comments
 (0)