diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..b7b85ab3a4 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -115,6 +115,8 @@ pub(crate) enum RequirementPayload { }, /// Git for Windows is missing; open Agent runtimes for the installation guide. GitBash, + /// A custom ACP harness command cannot be resolved in the desktop's PATH. + MissingBinary { command: String }, } impl RequirementPayload { @@ -189,6 +191,9 @@ impl RequirementPayload { RequirementPayload::GitBash => { "install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string() } + RequirementPayload::MissingBinary { command } => { + format!("install `{command}` or update PATH so Buzz Desktop can find it") + } } } } @@ -257,24 +262,49 @@ impl SetupPayload { .requirements .iter() .any(|r| matches!(r, RequirementPayload::GitBash)); - let all_external = self + let has_missing_binary = self .requirements .iter() - .all(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); - let any_external = self + .any(|r| matches!(r, RequirementPayload::MissingBinary { .. })); + let has_config_invalid = self .requirements .iter() .any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); + let all_external = self.requirements.iter().all(|r| { + matches!( + r, + RequirementPayload::CliConfigInvalid { .. } + | RequirementPayload::MissingBinary { .. } + ) + }); + let any_external = self.requirements.iter().any(|r| { + matches!( + r, + RequirementPayload::CliConfigInvalid { .. } + | RequirementPayload::MissingBinary { .. } + ) + }); let footer = if has_doctor_requirement { "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() } else if all_external { - // All requirements are external config files — Edit Agent cannot - // help. Don't send the user there. - "Fix the config file(s) and restart the agent.".to_string() + // External config and PATH failures cannot be fixed in Edit Agent. + match (has_missing_binary, has_config_invalid) { + (true, true) => "Install the missing harness command(s) or update PATH, fix the config file(s), then restart the agent.".to_string(), + (true, false) => "Install the missing harness command(s) or update PATH, then restart the agent.".to_string(), + (false, true) => { + "Fix the config file(s) and restart the agent.".to_string() + } + (false, false) => unreachable!("all_external requires an external requirement"), + } } else if any_external { - // Mixed: some Buzz-managed fields, some external config. - "Open Edit Agent in the Buzz app for the Buzz-managed fields; fix the external CLI config files manually and restart the agent.".to_string() + // Mixed: some Buzz-managed fields, plus external config or PATH. + match (has_missing_binary, has_config_invalid) { + (true, true) => "Open Edit Agent in the Buzz app for the Buzz-managed fields; install the missing harness command(s) or update PATH, fix the external CLI config files manually, and restart the agent.".to_string(), + (true, false) => "Open Edit Agent in the Buzz app for the Buzz-managed fields; install the missing harness command(s) or update PATH, then restart the agent.".to_string(), + (false, true) => "Open Edit Agent in the Buzz app for the Buzz-managed fields; fix the external CLI config files manually and restart the agent.".to_string(), + (false, false) => unreachable!("any_external requires an external requirement"), + } } else { // All Buzz-managed — original footer unchanged. "Open Edit Agent in the Buzz app to set these.".to_string() @@ -699,6 +729,49 @@ mod tests { )); } + #[test] + fn setup_payload_accepts_missing_binary_requirement() { + let payload: SetupPayload = serde_json::from_str( + r#"{"agent_name":"Custom Agent","agent_pubkey":"test","requirements":[{"surface":"missing_binary","command":"custom-acp"}]}"#, + ) + .unwrap(); + + let body = payload.nudge_body(); + assert!( + body.contains("custom-acp"), + "missing-binary nudge must name the unresolved command; got: {body:?}" + ); + assert!( + !body.contains("Open Edit Agent"), + "missing-binary setup cannot be fixed in Edit Agent; got: {body:?}" + ); + assert!( + body.contains("restart the agent"), + "missing-binary nudge must tell the user to restart after fixing PATH; got: {body:?}" + ); + } + + #[test] + fn nudge_body_mixed_missing_binary_uses_split_footer() { + let payload = SetupPayload { + agent_name: "Custom Agent".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::EnvKey { + key: "CUSTOM_API_KEY".to_string(), + }, + RequirementPayload::MissingBinary { + command: "custom-acp".to_string(), + }, + ], + }; + + let body = payload.nudge_body(); + assert!(body.contains("Open Edit Agent")); + assert!(body.contains("update PATH")); + assert!(body.contains("restart the agent")); + } + #[test] fn nudge_body_names_all_requirements() { let payload = SetupPayload { diff --git a/desktop/src/shared/ui/config-nudge-attachment.test.mjs b/desktop/src/shared/ui/config-nudge-attachment.test.mjs index e526be34fe..191ba5f1ed 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.test.mjs +++ b/desktop/src/shared/ui/config-nudge-attachment.test.mjs @@ -28,6 +28,7 @@ globalThis.window = { import { focusTargetForRequirement, + isInformationalOnly, shouldOpenDoctor, } from "./config-nudge-attachment.tsx"; import { @@ -60,6 +61,38 @@ test("shouldOpenDoctor_regularMixedRequirements_routesToEditAgent", () => { ); }); +test("isInformationalOnly_missingBinary_hasNoMisleadingEditAgentRoute", () => { + assert.equal( + isInformationalOnly([{ surface: "missing_binary", command: "custom-acp" }]), + true, + ); +}); + +test("isInformationalOnly_allExternalRequirements_hasNoInAppRoute", () => { + assert.equal( + isInformationalOnly([ + { surface: "missing_binary", command: "custom-acp" }, + { + surface: "cli_config_invalid", + probe_args: ["codex"], + setup_copy: "fix the config", + diagnostic: "invalid TOML", + }, + ]), + true, + ); +}); + +test("isInformationalOnly_externalMixedWithEditable_stillRoutesToEditAgent", () => { + assert.equal( + isInformationalOnly([ + { surface: "missing_binary", command: "custom-acp" }, + { surface: "env_key", key: "ANTHROPIC_API_KEY" }, + ]), + false, + ); +}); + // ── focusTargetForRequirement — pure function ───────────────────────────────── test("focusTargetForRequirement_envKey_returnsEnvKeyTarget", () => { diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index cc5931e23e..e2367cb84a 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -81,14 +81,21 @@ function isAuthOnly(reqs: ConfigNudgePayload["requirements"]): boolean { } /** - * Returns true when every requirement is a `cli_config_invalid` surface. - * Config-invalid cards are purely informational — the user must edit an - * external file; there is no in-app destination that can fix it. + * Returns true when the card has no in-app destination. Auth-only cards already + * contain the login command, while external config and PATH failures must be + * repaired outside Buzz. A mixed card remains actionable when at least one + * requirement maps to Edit Agent. */ -function isAllConfigInvalid(reqs: ConfigNudgePayload["requirements"]): boolean { - return ( - reqs.length > 0 && reqs.every((r) => r.surface === "cli_config_invalid") - ); +export function isInformationalOnly( + reqs: ConfigNudgePayload["requirements"], +): boolean { + const allExternal = + reqs.length > 0 && + reqs.every( + (r) => + r.surface === "cli_config_invalid" || r.surface === "missing_binary", + ); + return isAuthOnly(reqs) || allExternal; } /** @@ -189,11 +196,7 @@ export function ConfigNudgeCard({ const allCliLogin = isAllCliLogin(nudge.requirements); const opensDoctor = shouldOpenDoctor(nudge.requirements); - const authOnly = isAuthOnly(nudge.requirements); - const allConfigInvalid = isAllConfigInvalid(nudge.requirements); - // Any card that is purely informational (auth-only or all-config-invalid) - // has no clickable destination — treat them the same for affordance/routing. - const informationalOnly = authOnly || allConfigInvalid; + const informationalOnly = isInformationalOnly(nudge.requirements); const openDoctor = () => { if (!onOpenSettings) {