feat(config-nudge): distinguish install/auth state in cli_login nudge cards (#1633)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-08 16:00:03 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent dc7ececdcc
commit 4f1a487ab4
13 changed files with 954 additions and 92 deletions
+157 -2
View File
@@ -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,
@@ -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,
},
}
@@ -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)
}
},
}
}
}
@@ -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();
@@ -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,
},
],
};
@@ -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
View File
@@ -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",
+10 -1
View File
@@ -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
@@ -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)
}
@@ -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,
+235 -56
View File
@@ -45,9 +45,11 @@ use serde::{Deserialize, Serialize};
use crate::managed_agents::{
agent_env::baked_build_env,
config_bridge::read_goose_file_config,
discovery::{known_acp_runtime, resolve_command, KnownAcpRuntime},
discovery::{
classify_runtime, find_command, known_acp_runtime, resolve_command, KnownAcpRuntime,
},
env_vars::merged_user_env,
types::{ManagedAgentRecord, PersonaRecord},
types::{AcpAvailabilityStatus, ManagedAgentRecord, PersonaRecord},
};
// ── EffectiveAgentEnv ─────────────────────────────────────────────────────────
@@ -149,8 +151,13 @@ pub enum Requirement {
/// Arguments for the login-status probe (e.g. `["claude", "auth", "status"]`).
probe_args: Vec<String>,
/// Human-readable instruction for completing the login
/// (e.g. `"run \`codex login --with-api-key\`"`).
/// (e.g. `"run \`codex login\`"`).
setup_copy: String,
/// Granular install/auth state for this runtime — distinguishes
/// "not installed" from "logged out" from "adapter missing".
/// Carried to the FE so the nudge card can show the right message
/// and route to Doctor with accurate context.
availability: AcpAvailabilityStatus,
},
}
@@ -242,11 +249,9 @@ fn collect_missing_requirements(
"claude" => cli_login_requirements(
&["claude", "auth", "status"],
"complete Claude Code authentication by running the Claude CLI",
rt,
),
"codex" => cli_login_requirements(
&["codex", "login", "status"],
"run `codex login --with-api-key`",
),
"codex" => cli_login_requirements(&["codex", "login", "status"], "run `codex login`", rt),
_ => vec![],
}
}
@@ -422,39 +427,72 @@ fn goose_requirements(
/// Requirements for CLI-login runtimes (claude, codex).
///
/// We probe the CLI's login-status command synchronously. These probes are
/// Probes the CLI's login-status command synchronously. These probes are
/// fast (<300ms) and the results are memoized by the caller for the session
/// lifetime if desired.
fn cli_login_requirements(probe_args: &[&str], setup_copy: &str) -> Vec<Requirement> {
// Resolve the binary through the full PATH-search + login-shell path so
// the probe works in a packaged macOS DMG where the GUI PATH lacks
// npm/homebrew directories (where `claude` / `codex` typically live).
//
// If the binary genuinely does not exist, stay NotReady — the user needs
// to install it. If it exists but is not on the GUI PATH, resolve_command
// finds it via the login-shell fallback.
let Some(binary_path) = resolve_command(probe_args[0]) else {
// Binary not found → not installed → NotReady.
return vec![Requirement::CliLogin {
probe_args: probe_args.iter().map(|s| s.to_string()).collect(),
setup_copy: setup_copy.to_string(),
}];
};
///
/// Computes a granular `AcpAvailabilityStatus` by running the same
/// classifier as Doctor (`classify_runtime`) before deciding whether to
/// probe — this lets the nudge card distinguish "not installed" from
/// "adapter missing" from "logged out" without a new backend probe.
fn cli_login_requirements(
probe_args: &[&str],
setup_copy: &str,
runtime: &KnownAcpRuntime,
) -> Vec<Requirement> {
// Resolve each adapter command to find the ACP adapter binary.
let adapter_result = runtime
.commands
.iter()
.find_map(|cmd| find_command(cmd).map(|path| (*cmd, path)));
// Run the probe at the resolved absolute path so the GUI-PATH gap is bypassed.
let logged_in = std::process::Command::new(&binary_path)
.args(&probe_args[1..])
.output()
.map(|o| o.status.success())
// Check whether the underlying CLI itself (e.g. "claude", "codex") is on PATH.
let underlying_cli_found = runtime
.underlying_cli
.map(|cli| find_command(cli).is_some())
.unwrap_or(false);
if logged_in {
vec![]
} else {
vec![Requirement::CliLogin {
let (availability, _cmd, _path) =
classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
match availability {
AcpAvailabilityStatus::Available => {
// Both adapter and CLI are present — probe login status.
// Resolve via the full login-shell PATH so the probe works in a
// packaged macOS DMG where the GUI PATH lacks npm/homebrew.
let Some(binary_path) = resolve_command(probe_args[0]) else {
// Unexpectedly not resolvable (race or PATH edge case).
return vec![Requirement::CliLogin {
probe_args: probe_args.iter().map(|s| s.to_string()).collect(),
setup_copy: setup_copy.to_string(),
availability: AcpAvailabilityStatus::Available,
}];
};
let logged_in = std::process::Command::new(&binary_path)
.args(&probe_args[1..])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if logged_in {
vec![]
} else {
vec![Requirement::CliLogin {
probe_args: probe_args.iter().map(|s| s.to_string()).collect(),
setup_copy: setup_copy.to_string(),
availability: AcpAvailabilityStatus::Available,
}]
}
}
// Tooling is not fully installed — emit CliLogin with the precise
// state so the nudge card can show the right message. Skip the probe
// (can't run a missing or misconfigured CLI).
other => vec![Requirement::CliLogin {
probe_args: probe_args.iter().map(|s| s.to_string()).collect(),
setup_copy: setup_copy.to_string(),
}]
availability: other,
}],
}
}
@@ -802,14 +840,12 @@ mod tests {
#[test]
fn codex_not_ready_copy_does_not_mention_openai_api_key() {
// codex uses its own credential store via `codex login --with-api-key`.
// codex uses its own credential store via `codex login` (OAuth or API key).
// The nudge copy must NOT say "set OPENAI_API_KEY".
// We test the static not-logged-in path by constructing the requirement
// directly (the binary may or may not be installed in CI).
let reqs = cli_login_requirements(
&["codex", "login", "status"],
"run `codex login --with-api-key`",
);
// Use a not-installed runtime so the requirement is always emitted
// regardless of whether codex is on the test machine's PATH.
let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], None);
let reqs = cli_login_requirements(&["codex", "login", "status"], "run `codex login`", &rt);
// Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY.
for req in &reqs {
if let Requirement::CliLogin { setup_copy, .. } = req {
@@ -827,14 +863,71 @@ mod tests {
// ── cli_login_requirements: resolve_command integration ─────────────
/// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements.
/// `commands` are the adapter binaries; `underlying_cli` is the CLI name.
fn make_cli_runtime(
commands: &'static [&'static str],
underlying_cli: Option<&'static str>,
) -> KnownAcpRuntime {
KnownAcpRuntime {
id: "test-cli-runtime",
label: "Test CLI",
commands,
aliases: &[],
avatar_url: "",
mcp_command: None,
mcp_hooks: false,
underlying_cli,
cli_install_commands: &[],
adapter_install_commands: &[],
install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
skill_dir: None,
supports_acp_model_switching: false,
config_file_path: None,
config_file_format: None,
model_env_var: None,
provider_env_var: None,
provider_locked: false,
default_env: &[],
supports_acp_native_config: false,
thinking_env_var: None,
max_tokens_env_var: None,
context_limit_env_var: None,
required_normalized_fields: &[],
}
}
/// Returns the absolute path of the currently-running test binary as a
/// `&'static str`. Host-portable stand-in for a "present" binary:
/// the path is absolute so `find_command` resolves it via `path.exists()`
/// rather than searching `PATH`, and the file always exists on the host.
///
/// The tiny allocation is intentionally leaked — this runs at most once per
/// test process and the process exits immediately after tests complete.
fn present_binary_str() -> &'static str {
let path = std::env::current_exe().expect("current_exe must be available in tests");
Box::leak(path.to_string_lossy().into_owned().into_boxed_str())
}
/// Leak a runtime slice of `'static` strs for use in `make_cli_runtime`.
fn static_commands(commands: Vec<&'static str>) -> &'static [&'static str] {
Box::leak(commands.into_boxed_slice())
}
#[test]
fn cli_login_requirements_missing_binary_is_not_ready() {
// A binary that cannot possibly exist on any systembinary not found
// → resolve_command returns None → function must return CliLogin
// requirement (NotReady), not panic or return Ready.
// Both adapter and underlying CLI are nonexistentNotInstalled state
// → must return a CliLogin requirement with availability=NotInstalled.
let rt = make_cli_runtime(
&["__buzz_nonexistent_adapter_abc123__"],
Some("__buzz_nonexistent_cli_abc123__"),
);
let reqs = cli_login_requirements(
&["__buzz_nonexistent_binary_abc123__", "status"],
"install the tool first",
&rt,
);
assert!(
!reqs.is_empty(),
@@ -845,31 +938,116 @@ mod tests {
"requirement must be CliLogin; got {:?}",
reqs[0]
);
if let Requirement::CliLogin {
ref availability, ..
} = reqs[0]
{
assert_eq!(
*availability,
crate::managed_agents::AcpAvailabilityStatus::NotInstalled,
"both missing → NotInstalled"
);
}
}
#[test]
fn cli_login_requirements_adapter_missing_emits_adapter_missing() {
// Underlying CLI present (use the running test binary as a portable
// stand-in — it's always present and resolves via absolute path),
// adapter absent.
// → AdapterMissing state → no probe run → CliLogin{AdapterMissing}.
let exe = present_binary_str();
let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], Some(exe));
let reqs = cli_login_requirements(&[exe, "--list"], "install the adapter", &rt);
assert!(
!reqs.is_empty(),
"adapter missing must produce a CliLogin requirement"
);
if let Requirement::CliLogin {
ref availability, ..
} = reqs[0]
{
assert_eq!(
*availability,
crate::managed_agents::AcpAvailabilityStatus::AdapterMissing,
"adapter absent, CLI present → AdapterMissing"
);
}
}
#[test]
fn cli_login_requirements_cli_missing_emits_cli_missing() {
// Adapter present (use the running test binary as a portable stand-in),
// underlying CLI absent.
// → CliMissing state → no probe run → CliLogin{CliMissing}.
let exe = present_binary_str();
let rt = make_cli_runtime(
static_commands(vec![exe]), // adapter found via absolute path
Some("__buzz_nonexistent_cli_abc123__"), // underlying CLI missing
);
let reqs = cli_login_requirements(&[exe, "--list"], "install the CLI", &rt);
assert!(
!reqs.is_empty(),
"CLI missing must produce a CliLogin requirement"
);
if let Requirement::CliLogin {
ref availability, ..
} = reqs[0]
{
assert_eq!(
*availability,
crate::managed_agents::AcpAvailabilityStatus::CliMissing,
"adapter present, CLI absent → CliMissing"
);
}
}
#[test]
fn cli_login_requirements_resolvable_binary_runs_probe_at_resolved_path() {
// `true` is a POSIX built-in available on every CI runner and always
// exits 0. Use it as a probe: probe_args = ["true", "--probe-arg"]
// → resolved path probed → exit 0 → logged_in = true → requirements
// is empty (Ready). This exercises the resolve_command fast path
// (binary found on PATH) + the probe-at-resolved-path branch.
//
// `true` is universally resolvable on POSIX/macOS/Linux CI, so we
// assert the ready (empty) outcome directly rather than hedging.
// Both adapter and CLI present (use the running test binary as a
// portable stand-in — always present, resolves via absolute path),
// probe exits 0 (run with `--list` which lists tests and exits 0).
// → logged_in = true → requirements is empty (Ready).
let exe = present_binary_str();
let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe));
let reqs = cli_login_requirements(
&["true", "--probe-arg"],
"this should not show (true always succeeds)",
&[exe, "--list"],
"this should not show (probe exits 0)",
&rt,
);
assert!(
reqs.is_empty(),
"expected Ready (no requirements) when probe binary resolves and exits 0; \
got {:?} `true` is always on PATH on POSIX/macOS/Linux CI so this \
must be empty",
got {:?}",
reqs
);
}
#[test]
fn cli_login_requirements_logged_out_emits_available() {
// Both adapter and CLI present, but probe exits non-zero (logged out).
// Use the test binary with an unrecognized argument as the probe —
// libtest exits non-zero for unknown flags on all platforms.
// → CliLogin{Available} (tooling installed, needs login).
let exe = present_binary_str();
let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe));
let reqs = cli_login_requirements(&[exe, "--buzz-probe-fail-xyz"], "run `tool login`", &rt);
assert!(
!reqs.is_empty(),
"non-zero probe must produce a CliLogin requirement (logged out)"
);
if let Requirement::CliLogin {
ref availability, ..
} = reqs[0]
{
assert_eq!(
*availability,
crate::managed_agents::AcpAvailabilityStatus::Available,
"tooling installed, probe fails → Available (logged-out)"
);
}
}
// ── custom/unknown command ─────────────────────────────────────────────
#[test]
@@ -929,7 +1107,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: crate::managed_agents::AcpAvailabilityStatus::Available,
};
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["surface"], "cli_login");
@@ -1636,10 +1636,12 @@ pub fn spawn_agent_child(
Requirement::CliLogin {
probe_args,
setup_copy,
availability,
} => serde_json::json!({
"surface": "cli_login",
"probe_args": probe_args,
"setup_copy": setup_copy,
"availability": availability,
}),
})
.collect();
@@ -551,7 +551,7 @@ pub struct ManagedAgentLogResponse {
pub log_path: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AcpAvailabilityStatus {
Available,
+1
View File
@@ -637,6 +637,7 @@ export function AppShell() {
threadActivityItems,
threadActivityFeedItems,
feedItemState,
onOpenSettings: handleOpenSettings,
}}
>
<HuddleProvider>
+6
View File
@@ -3,6 +3,7 @@ import type { ContextParentResolver } from "@/features/channels/readState/readSt
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import type { FeedItemState } from "@/features/home/useFeedItemState";
import type { FeedItem } from "@/shared/api/types";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
const EMPTY_SET = new Set<string>();
@@ -45,6 +46,10 @@ type AppShellContextValue = {
threadActivityItems: ThreadActivityItem[];
threadActivityFeedItems: FeedItem[];
feedItemState: FeedItemState;
// Open the Settings panel at the given section. Available on all surfaces
// that render under AppShell (channel, home, projects, pulse, agents).
// Used by config-nudge cards to deep-link to Settings → Doctor.
onOpenSettings: ((section: SettingsSection) => void) | null;
};
const AppShellContext = React.createContext<AppShellContextValue>({
@@ -75,6 +80,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
undoUnread: () => {},
unreadSet: EMPTY_SET,
},
onOpenSettings: null,
});
export function AppShellProvider({
+22 -1
View File
@@ -62,13 +62,33 @@ test("extractConfigNudge parses cli_login requirement", () => {
{
surface: "cli_login",
probe_args: ["codex", "login", "status"],
setup_copy: "run `codex login --with-api-key`",
setup_copy: "run `codex login`",
availability: "available",
},
],
};
assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload);
});
test("extractConfigNudge returns null for cli_login without availability", () => {
// availability is required — old-format payloads (no availability field)
// must not parse so stale nudge JSON from before the Doctor-CTA update
// does not silently render a broken card.
const payload = {
agent_name: "Codex",
agent_pubkey: CODEX_PUBKEY,
requirements: [
{
surface: "cli_login",
probe_args: ["codex", "login", "status"],
setup_copy: "run `codex login`",
// no availability field
},
],
};
assert.equal(extractConfigNudge(withSentinel("prose", payload)), null);
});
test("extractConfigNudge parses multiple requirements of mixed types", () => {
const payload = {
agent_name: "Atlas",
@@ -80,6 +100,7 @@ test("extractConfigNudge parses multiple requirements of mixed types", () => {
surface: "cli_login",
probe_args: ["codex", "login"],
setup_copy: "run `codex login`",
availability: "not_installed",
},
],
};
+16 -1
View File
@@ -17,6 +17,8 @@
// ── Types ─────────────────────────────────────────────────────────────────────
import type { AcpAvailabilityStatus } from "@/shared/api/types";
/** A single missing requirement — mirrors the Rust `RequirementPayload` enum. */
export type ConfigNudgeRequirement =
| { surface: "normalized_field"; field: string }
@@ -25,6 +27,15 @@ export type ConfigNudgeRequirement =
surface: "cli_login";
probe_args: string[];
setup_copy: string;
/**
* Granular install/auth state mirrors `AcpAvailabilityStatus` from Rust.
* Determines which message and CTA the nudge card shows:
* - "available" tooling installed, needs login
* - "adapter_missing" CLI installed but ACP adapter missing
* - "cli_missing" ACP adapter installed but CLI missing
* - "not_installed" neither adapter nor CLI found
*/
availability: AcpAvailabilityStatus;
};
/**
@@ -114,7 +125,11 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement {
return (
Array.isArray(r.probe_args) &&
r.probe_args.every((a) => typeof a === "string") &&
typeof r.setup_copy === "string"
typeof r.setup_copy === "string" &&
(r.availability === "available" ||
r.availability === "adapter_missing" ||
r.availability === "cli_missing" ||
r.availability === "not_installed")
);
default:
return false;
+201 -28
View File
@@ -1,6 +1,7 @@
import { AlertTriangle } from "lucide-react";
import { requestOpenEditAgent } from "@/features/agents/openEditAgentEvent";
import { useAppShell } from "@/app/AppShellContext";
import type { ConfigNudgePayload } from "@/shared/lib/configNudge";
import { cn } from "@/shared/lib/cn";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
@@ -32,6 +33,57 @@ function requirementKey(
}
}
/**
* Returns true when every requirement in the nudge is a `cli_login` surface.
* Non-authOnly all-cli_login cards (at least one install-state row) route to
* Doctor install/login problems can't be fixed in Edit Agent. AuthOnly cards
* (every row is `availability === "available"`) are purely informational and
* do not route anywhere.
*/
function isAllCliLogin(reqs: ConfigNudgePayload["requirements"]): boolean {
return reqs.length > 0 && reqs.every((r) => r.surface === "cli_login");
}
/**
* Returns true when the card is all-cli_login AND every requirement is in the
* `available` state (tooling installed, just needs login). In this case Doctor
* has no auth functionality and is a misleading dead-end the card becomes
* purely informational (no trigger, no CTA, no pointer/hover affordance).
*/
function isAuthOnly(reqs: ConfigNudgePayload["requirements"]): boolean {
return (
reqs.length > 0 &&
reqs.every(
(r) => r.surface === "cli_login" && r.availability === "available",
)
);
}
/**
* Per-state human-readable copy for a cli_login requirement.
* Uses the probe_args[0] as a best-effort harness name.
*/
function cliLoginMessage(
req: Extract<
ConfigNudgePayload["requirements"][number],
{ surface: "cli_login" }
>,
): string {
const harness = req.probe_args[0] ?? "the CLI tool";
switch (req.availability) {
case "not_installed":
return `${harness} isn't installed`;
case "cli_missing":
return `${harness} CLI is missing`;
case "adapter_missing":
return `${harness} ACP adapter isn't installed`;
case "available":
// Tooling is present but authentication is needed — fall back to
// the backend-supplied copy which has the exact login command.
return req.setup_copy;
}
}
/**
* Inline card rendered when the desktop detects a `buzz:config-nudge`
* sentinel in a kind:9 message body.
@@ -40,11 +92,24 @@ function requirementKey(
* variant so it is visually distinct and consistent with other error states in
* the system.
*
* The card's trigger opens the Edit Agent dialog for the agent by:
* 1. Calling `openProfilePanel(pubkey)` (from ProfilePanelContext) to ensure
* the profile panel is visible.
* 2. Dispatching `requestOpenEditAgent(pubkey)` so that `UserProfilePanel`
* auto-opens the edit dialog once it mounts with that pubkey.
* Routing:
* (A) When ALL requirements are `cli_login` in an install state
* (`not_installed` / `cli_missing` / `adapter_missing`): the card trigger
* opens Settings Doctor. A card-level "Open Doctor →" label in
* `AttachmentActions` confirms the action at rest.
* (A-auth) When ALL requirements are `cli_login` with `availability ===
* "available"` (tooling installed, just needs login): Doctor has no auth
* functionality and would be a misleading dead-end. The card is purely
* informational no trigger, no CTA, no pointer/hover affordance. The
* inline copy (`setup_copy`) already tells the user the exact command.
* (B) Otherwise (mixed card), the card trigger opens Edit Agent as the
* card-level fallback. Each row carries its own inline CTA sharing one
* right edge so the actions are clearly paired with their requirement:
* - `cli_login` rows in an install state "Open Doctor →" (Doctor).
* - `cli_login` rows with `availability === "available"` no per-row CTA.
* - `env_key` / `normalized_field` rows "Edit Agent →" (Edit Agent).
* The `AttachmentActions` column is omitted on mixed cards per-row CTAs
* replace it.
*/
export function ConfigNudgeCard({
className,
@@ -54,15 +119,60 @@ export function ConfigNudgeCard({
nudge: ConfigNudgePayload;
}) {
const { openProfilePanel } = useProfilePanel();
const { onOpenSettings } = useAppShell();
const handleOpen = () => {
const allCliLogin = isAllCliLogin(nudge.requirements);
const authOnly = isAuthOnly(nudge.requirements);
const openDoctor = () => {
if (!onOpenSettings) {
console.warn(
"[ConfigNudgeCard] onOpenSettings is null — Doctor deep-link unavailable on this surface",
);
}
onOpenSettings?.("doctor");
};
const openEditAgent = () => {
openProfilePanel?.(nudge.agent_pubkey);
requestOpenEditAgent(nudge.agent_pubkey);
};
const handleOpen = () => {
if (allCliLogin) {
// (A) Non-authOnly install-state all-cli_login card — route to Doctor.
// AuthOnly cards never mount this trigger, so this branch only runs for
// install-state cards where Doctor is the correct destination.
openDoctor();
} else {
// (B) Mixed card — card-level fallback to Edit Agent.
openEditAgent();
}
};
const handleOpenDoctor = (e: React.MouseEvent) => {
// (B) Per-row Doctor CTA — stop propagation so the card trigger doesn't
// double-fire to Edit Agent on mixed cards.
e.stopPropagation();
openDoctor();
};
const handleOpenEditAgent = (e: React.MouseEvent) => {
// (B) Per-row Edit Agent CTA — stop propagation so the card trigger
// doesn't double-fire.
e.stopPropagation();
openEditAgent();
};
return (
<Attachment
className={cn("max-w-[min(100%,32rem)] shrink-0 shadow-none", className)}
className={cn(
"max-w-[min(100%,32rem)] shrink-0 shadow-none",
// Affordance: cursor-pointer + subtle hover lift — omitted for auth-only
// cards which are purely informational (no click destination).
!authOnly && "cursor-pointer hover:shadow-sm",
className,
)}
orientation="horizontal"
state="error"
>
@@ -75,50 +185,113 @@ export function ConfigNudgeCard({
</AttachmentTitle>
<div className="mt-1 flex flex-col gap-0.5">
{nudge.requirements.map((req, i) => (
<RequirementRow key={requirementKey(req, i)} requirement={req} />
<RequirementRow
key={requirementKey(req, i)}
allCliLogin={allCliLogin}
onOpenDoctor={handleOpenDoctor}
onOpenEditAgent={handleOpenEditAgent}
requirement={req}
/>
))}
</div>
</AttachmentContent>
<AttachmentActions>
<span className="text-xs text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100">
Edit Agent
</span>
</AttachmentActions>
<AttachmentTrigger
aria-label={`Open Edit Agent for ${nudge.agent_name}`}
onClick={handleOpen}
/>
{/* (A) Install-state all-cli_login card only single card-level CTA
confirming the action at rest. Auth-only cards are informational
(no CTA); mixed cards render per-row CTAs instead. */}
{allCliLogin && !authOnly && (
<AttachmentActions className="items-end self-end">
<span className="text-xs text-muted-foreground">Open Doctor </span>
</AttachmentActions>
)}
{/* Auth-only cards are purely informational — no trigger, no routing. */}
{!authOnly && (
<AttachmentTrigger
aria-label={
allCliLogin
? `Open Doctor settings for ${nudge.agent_name}`
: `Open Edit Agent for ${nudge.agent_name}`
}
onClick={handleOpen}
/>
)}
</Attachment>
);
}
function RequirementRow({
allCliLogin,
onOpenDoctor,
onOpenEditAgent,
requirement,
}: {
allCliLogin: boolean;
onOpenDoctor: (e: React.MouseEvent) => void;
onOpenEditAgent: (e: React.MouseEvent) => void;
requirement: ConfigNudgePayload["requirements"][number];
}) {
switch (requirement.surface) {
case "env_key":
return (
<div className="text-xs leading-4 text-muted-foreground [overflow-wrap:anywhere]">
Set{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
{requirement.key}
</code>{" "}
in Edit Agent Environment variables
<div className="flex items-center gap-2 text-xs leading-4 text-muted-foreground">
<span className="flex-1 [overflow-wrap:anywhere]">
Set{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
{requirement.key}
</code>{" "}
in Edit Agent Environment variables
</span>
{!allCliLogin && (
<button
className="relative z-20 shrink-0 font-medium text-muted-foreground hover:underline"
onClick={onOpenEditAgent}
type="button"
>
Edit Agent
</button>
)}
</div>
);
case "normalized_field":
return (
<div className="text-xs leading-4 text-muted-foreground [overflow-wrap:anywhere]">
Set the <strong>{requirement.field}</strong> field in Edit Agent
dropdowns
<div className="flex items-center gap-2 text-xs leading-4 text-muted-foreground">
<span className="flex-1 [overflow-wrap:anywhere]">
Set the <strong>{requirement.field}</strong> field in Edit Agent
dropdowns
</span>
{!allCliLogin && (
<button
className="relative z-20 shrink-0 font-medium text-muted-foreground hover:underline"
onClick={onOpenEditAgent}
type="button"
>
Edit Agent
</button>
)}
</div>
);
case "cli_login":
return (
<div className="text-xs leading-4 text-muted-foreground">
{requirement.setup_copy}
<div className="flex items-center gap-2 text-xs leading-4 text-muted-foreground">
<span className="flex-1 [overflow-wrap:anywhere]">
{cliLoginMessage(requirement)}
</span>
{/* (B) Per-row Doctor CTA shown only on mixed cards where the
card-level trigger opens Edit Agent (not auth-only cards). When
allCliLogin is true the card trigger already routes to Doctor; the
per-row button is redundant and is suppressed. Also suppressed for
`available` cli_login rows Doctor has no auth functionality and
the setup_copy already provides the exact login command.
stopPropagation prevents double-fire on mixed cards where both
card and row CTAs are visible. */}
{!allCliLogin && requirement.availability !== "available" && (
<button
className="relative z-20 shrink-0 font-medium text-muted-foreground hover:underline"
onClick={onOpenDoctor}
type="button"
>
Open Doctor
</button>
)}
</div>
);
}
@@ -0,0 +1,300 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/doctor-cta";
// Channel ID for the seeded #general mock channel.
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
// Tyler is a managed agent seed in the mock bridge — used as the nudge sender.
const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const AGENT_NAME = "Tyler Agent";
/** Settle CSS / Web Animations before capture. */
async function settleAnimations(page: import("@playwright/test").Page) {
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
);
}
/**
* Wait for the Tauri invoke mock to be available, then call a mock command.
* Mirrors the helper used in config-bridge-screenshots.spec.ts.
*/
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<unknown> {
await page.waitForFunction(
() => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
return page.evaluate(
async ({ command: cmd, payload: pl }) => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return invoke(cmd, pl);
},
{ command, payload },
);
}
/**
* Build a `buzz:config-nudge` sentinel body from a requirements array.
* Mirrors the format nudge_body() in setup_mode.rs produces.
*/
function makeNudgeSentinel(
agentName: string,
agentPubkey: string,
requirements: unknown[],
): string {
const payload = JSON.stringify({
agent_name: agentName,
agent_pubkey: agentPubkey,
requirements,
});
return `**${agentName}** needs configuration before it can respond.\n\n\`\`\`buzz:config-nudge\n${payload}\n\`\`\``;
}
/**
* Inject a nudge message from the agent into #general, then navigate to the
* channel so the card renders in the message list.
*/
async function injectNudgeAndNavigate(
page: import("@playwright/test").Page,
content: string,
): Promise<void> {
await invokeMockCommand(page, "send_managed_agent_channel_message", {
agentPubkey: AGENT_PUBKEY,
channelId: GENERAL_CHANNEL_ID,
content,
});
// Navigate to #general — the injected message is live-pushed so the card
// renders in the current message list.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
test.describe("doctor CTA nudge card screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
/**
* 01 pure cli_login card (all requirements are cli_login, availability=available):
* Tooling is installed but needs login Doctor has no auth functionality
* and would be a misleading dead-end. The card is purely informational:
* no trigger, no CTA, no pointer/hover affordance. The inline copy
* ("run `claude auth login` to authenticate") already tells the user
* the exact command to run.
*/
test("01-cli-login-available-informational", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "running" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "run `claude auth login` to authenticate",
availability: "available",
},
]);
await injectNudgeAndNavigate(page, content);
// Wait for the nudge card to render.
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
// Auth-only card is informational — no Doctor CTA anywhere.
await expect(card.getByText("Open Doctor →")).toHaveCount(0);
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/01-cli-login-available-informational.png`,
});
});
/**
* 02 not_installed state: neither adapter nor CLI found.
* Card shows "claude isn't installed" copy + inline "Open Doctor →" CTA.
*/
test("02-cli-login-not-installed-state", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install Claude Code",
availability: "not_installed",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText(/isn't installed/)).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/02-cli-login-not-installed-state.png`,
});
});
/**
* 03 mixed card: one cli_login (adapter_missing) + one env_key requirement.
* Each requirement row owns its CTA, right-aligned to a shared edge:
* the cli_login row shows "Open Doctor →" and the env_key row shows
* "Edit Agent →", both at the same x (vertically aligned).
*/
test("03-mixed-requirements-inline-doctor-cta", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install the Claude Code ACP adapter",
availability: "adapter_missing",
},
{
surface: "env_key",
key: "ANTHROPIC_API_KEY",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
// Mixed card: cli_login row shows "Open Doctor →", env_key row shows "Edit Agent →".
await expect(card.getByText("Open Doctor →")).toBeVisible();
// Both per-row CTAs share the same right edge (vertically aligned).
await expect(card.getByText("Edit Agent →", { exact: true })).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/03-mixed-requirements-inline-doctor-cta.png`,
});
});
/**
* 04 cli_missing state: ACP adapter present but underlying CLI absent.
* Shows "claude CLI is missing" copy.
*/
test("04-cli-login-cli-missing-state", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install the Claude CLI",
availability: "cli_missing",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText(/CLI is missing/)).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/04-cli-login-cli-missing-state.png`,
});
});
});