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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 81 additions & 8 deletions crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions desktop/src/shared/ui/config-nudge-attachment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ globalThis.window = {

import {
focusTargetForRequirement,
isInformationalOnly,
shouldOpenDoctor,
} from "./config-nudge-attachment.tsx";
import {
Expand Down Expand Up @@ -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", () => {
Expand Down
27 changes: 15 additions & 12 deletions desktop/src/shared/ui/config-nudge-attachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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) {
Expand Down