Skip to content
Merged
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
159 changes: 157 additions & 2 deletions crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ use nostr::EventId;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

// ── Availability mirror ────────────────────────────────────────────────────────

/// Granular install/auth state for a CLI-backed ACP harness.
///
/// Mirrors the desktop `AcpAvailabilityStatus` enum (and the FE
/// `AcpAvailabilityStatus` type in `api/types.ts`). Carried on
/// `RequirementPayload::CliLogin` so the sentinel JSON the desktop parses
/// contains the exact wire literals the FE expects.
///
/// buzz-acp is a separate crate and must NOT depend on desktop types —
/// this explicit mirror is the correct pattern (same as the rest of
/// `RequirementPayload` as "the Rust counterpart to desktop's `Requirement`").
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AcpAvailabilityStatus {
/// Adapter + CLI both present; may still need login.
Available,
/// ACP adapter binary missing; underlying CLI may be present.
AdapterMissing,
/// CLI binary missing; ACP adapter may be present.
CliMissing,
/// Neither adapter nor CLI found.
NotInstalled,
}

use crate::{
author_allowed,
config::Config,
Expand Down Expand Up @@ -72,6 +97,11 @@ pub(crate) enum RequirementPayload {
CliLogin {
probe_args: Vec<String>,
setup_copy: String,
/// Granular install/auth state — determines copy and CTA routing on
/// the desktop card. `Available` means tooling is present but login
/// is needed; the other three variants mean the tooling itself is
/// missing and the probe was skipped.
availability: AcpAvailabilityStatus,
},
}

Expand All @@ -85,7 +115,40 @@ impl RequirementPayload {
RequirementPayload::EnvKey { key } => {
format!("set `{}` in Edit Agent → Environment variables", key)
}
RequirementPayload::CliLogin { setup_copy, .. } => setup_copy.clone(),
RequirementPayload::CliLogin {
setup_copy,
availability,
probe_args,
} => match availability {
AcpAvailabilityStatus::Available => setup_copy.clone(),
AcpAvailabilityStatus::AdapterMissing => {
let harness = probe_args
.first()
.map(String::as_str)
.unwrap_or("the agent");
format!(
"install the {} ACP adapter (open Doctor in Settings to diagnose)",
harness
)
}
AcpAvailabilityStatus::CliMissing => {
let harness = probe_args
.first()
.map(String::as_str)
.unwrap_or("the agent");
format!(
"install {} CLI (open Doctor in Settings to diagnose)",
harness
)
}
AcpAvailabilityStatus::NotInstalled => {
let harness = probe_args
.first()
.map(String::as_str)
.unwrap_or("the agent");
format!("install {} (open Doctor in Settings to diagnose)", harness)
}
},
}
}
}
Expand Down Expand Up @@ -588,7 +651,8 @@ mod tests {
"login".to_string(),
"status".to_string(),
],
setup_copy: "run `codex login --with-api-key`".to_string(),
setup_copy: "run `codex login`".to_string(),
availability: AcpAvailabilityStatus::Available,
}],
};
let body = payload.nudge_body();
Expand Down Expand Up @@ -655,6 +719,7 @@ mod tests {
RequirementPayload::CliLogin {
probe_args: vec!["codex".to_string(), "login".to_string()],
setup_copy: "run `codex login`".to_string(),
availability: AcpAvailabilityStatus::Available,
},
],
};
Expand Down Expand Up @@ -766,4 +831,94 @@ mod tests {
"replay of the same event-id must be rejected (dedup)"
);
}

// ── availability round-trip tests ─────────────────────────────────────────
//
// These tests prove the desktop→buzz-acp→sentinel path preserves the
// `availability` field. They simulate what actually happens at runtime:
// desktop serializes a `cli_login` JSON blob → buzz-acp parses it via
// `from_raw_env_value` → `nudge_body()` re-serializes into the sentinel →
// the sentinel JSON is extracted and checked for the `availability` field.
//
// This guards the prior regression where `RequirementPayload::CliLogin`
// had no `availability` field, so serde silently dropped it during
// deserialization and the desktop card never rendered.

fn extract_sentinel_json(body: &str) -> String {
let fence_open = "```buzz:config-nudge\n";
let fence_close = "\n```";
let start = body
.rfind(fence_open)
.expect("sentinel open fence not found")
+ fence_open.len();
let end = body[start..]
.rfind(fence_close)
.expect("sentinel close fence not found")
+ start;
body[start..end].to_string()
}

fn make_desktop_cli_login_json(availability: &str) -> String {
// Simulate the JSON desktop's runtime.rs emits — a full SetupPayload
// with one cli_login requirement carrying the given availability state.
format!(
r#"{{"agent_name":"TestAgent","agent_pubkey":"aa","requirements":[{{"surface":"cli_login","probe_args":["claude"],"setup_copy":"run claude login","availability":"{availability}"}}]}}"#
)
}

#[test]
fn cli_login_availability_available_survives_sentinel_round_trip() {
let raw = make_desktop_cli_login_json("available");
let payload = SetupPayload::from_raw_env_value(Some(raw))
.unwrap()
.expect("must parse");
let body = payload.nudge_body();
let sentinel_json = extract_sentinel_json(&body);
assert!(
sentinel_json.contains(r#""availability":"available""#),
"sentinel must carry availability=available; got: {sentinel_json:?}"
);
}

#[test]
fn cli_login_availability_adapter_missing_survives_sentinel_round_trip() {
let raw = make_desktop_cli_login_json("adapter_missing");
let payload = SetupPayload::from_raw_env_value(Some(raw))
.unwrap()
.expect("must parse");
let body = payload.nudge_body();
let sentinel_json = extract_sentinel_json(&body);
assert!(
sentinel_json.contains(r#""availability":"adapter_missing""#),
"sentinel must carry availability=adapter_missing; got: {sentinel_json:?}"
);
}

#[test]
fn cli_login_availability_cli_missing_survives_sentinel_round_trip() {
let raw = make_desktop_cli_login_json("cli_missing");
let payload = SetupPayload::from_raw_env_value(Some(raw))
.unwrap()
.expect("must parse");
let body = payload.nudge_body();
let sentinel_json = extract_sentinel_json(&body);
assert!(
sentinel_json.contains(r#""availability":"cli_missing""#),
"sentinel must carry availability=cli_missing; got: {sentinel_json:?}"
);
}

#[test]
fn cli_login_availability_not_installed_survives_sentinel_round_trip() {
let raw = make_desktop_cli_login_json("not_installed");
let payload = SetupPayload::from_raw_env_value(Some(raw))
.unwrap()
.expect("must parse");
let body = payload.nudge_body();
let sentinel_json = extract_sentinel_json(&body);
assert!(
sentinel_json.contains(r#""availability":"not_installed""#),
"sentinel must carry availability=not_installed; got: {sentinel_json:?}"
);
}
}
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default defineConfig({
"**/local-archive-screenshots.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
Expand Down
11 changes: 10 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,16 @@ const overrides = new Map([
// +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix.
// +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase).
// +2 unit tests for cli_login_requirements resolve_command integration (DMG PATH fix).
["src-tauri/src/managed_agents/readiness.rs", 1215],
// Doctor-CTA: reworked cli_login_requirements to carry AcpAvailabilityStatus,
// skip login probe for not-installed/adapter-missing/cli-missing states, and
// added 4 unit tests covering each arm. Load-bearing discoverability fix.
// Updated existing codex_not_ready test to use make_cli_runtime stub.
// +4 lines: #1640 persona-env-vars-refresh rebase added availability-classification
// growth in the live-persona env merge path. Feature plumbing, not generic debt.
// Windows-CI portability: replaced POSIX true/false probes with current_exe()
// stand-in + present_binary_str()/static_commands() helpers (+29 lines).
// Tests now pass on windows-latest CI shard without POSIX shell utilities.
["src-tauri/src/managed_agents/readiness.rs", 1403],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ pub fn login_shell_path() -> Option<String> {
.clone()
}

fn find_command(command: &str) -> Option<PathBuf> {
pub(crate) fn find_command(command: &str) -> Option<PathBuf> {
resolve_command(command)
}

Expand All @@ -701,7 +701,7 @@ pub fn missing_command_message(command: &str, role: &str) -> String {
)
}

fn classify_runtime(
pub(crate) fn classify_runtime(
adapter_result: Option<(&str, PathBuf)>,
underlying_cli: Option<&str>,
underlying_cli_found: bool,
Expand Down
Loading
Loading