mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
1136 lines
43 KiB
Rust
1136 lines
43 KiB
Rust
//! Setup-mode listener for not-ready agents.
|
|
//!
|
|
//! When the desktop determines an agent is `NotReady` (missing provider,
|
|
//! model, or credentials), it spawns `buzz-acp` with a setup-mode payload
|
|
//! instead of starting the normal agent pool. This module implements that
|
|
//! early-branch path:
|
|
//!
|
|
//! ```text
|
|
//! Config::from_cli()
|
|
//! └─ SetupPayload::from_env()?
|
|
//! ├─ Some(payload) → run_setup_listener(config, payload) [this module]
|
|
//! └─ None → normal pool path (unchanged)
|
|
//! ```
|
|
//!
|
|
//! # Contract (NON-NEGOTIABLE)
|
|
//!
|
|
//! * **Desktop is the ONLY readiness source.** `buzz-acp` trusts the payload
|
|
//! passed by the desktop and does NOT re-derive readiness.
|
|
//! * **Normal startup gains no second readiness path.** The early branch is
|
|
//! entered only when `BUZZ_ACP_SETUP_PAYLOAD` is set.
|
|
//! * `spawn_key_refusal`-class identity failures are outside this path: no
|
|
//! valid key → no safe process to post as the agent.
|
|
//!
|
|
//! # Nudge mechanics
|
|
//!
|
|
//! Connect → subscribe → on each matching event:
|
|
//! 1. Apply `ignore_self` gate.
|
|
//! 2. Apply `author_allowed` gate (same as normal mode) so the nudge goes
|
|
//! only to authors the real agent would answer.
|
|
//! 3. Require an explicit @mention via `event_mentions_agent`.
|
|
//! 4. Apply `filter::match_event` so channel/kind rules still constrain.
|
|
//! 5. Build and publish a nudge reply (surface-correct copy).
|
|
//! 6. Deduplicate by event-id (reconnect replay must not double-nudge).
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use anyhow::Result;
|
|
use buzz_core::kind::{
|
|
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
|
|
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
|
};
|
|
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,
|
|
/// ACP adapter binary is from the deprecated package (< 1.0). Reinstall required.
|
|
AdapterOutdated,
|
|
/// CLI binary missing; ACP adapter may be present.
|
|
CliMissing,
|
|
/// Neither adapter nor CLI found.
|
|
NotInstalled,
|
|
}
|
|
|
|
use crate::{
|
|
author_allowed,
|
|
config::Config,
|
|
event_mentions_agent, filter,
|
|
relay::{HarnessRelay, RelayEventPublisher},
|
|
};
|
|
|
|
// ── Payload ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Env var carrying the JSON-encoded setup payload.
|
|
pub(crate) const SETUP_PAYLOAD_ENV_VAR: &str = "BUZZ_ACP_SETUP_PAYLOAD";
|
|
|
|
/// A single missing requirement, surface-discriminated so the nudge copy
|
|
/// names exactly what to set and where.
|
|
///
|
|
/// This is the Rust counterpart to the desktop's `Requirement` type
|
|
/// (`managed_agents/readiness.rs`). Desktop serializes it; `buzz-acp`
|
|
/// deserializes and renders copy from it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "surface", rename_all = "snake_case")]
|
|
pub(crate) enum RequirementPayload {
|
|
/// A normalized dropdown field (provider or model) missing.
|
|
NormalizedField { field: String },
|
|
/// An env-backed credential that is absent.
|
|
EnvKey { key: String },
|
|
/// A CLI authentication step that must be completed interactively.
|
|
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,
|
|
},
|
|
/// The CLI is installed but its config file could not be parsed.
|
|
/// Informational only — no in-app action can fix an external config file.
|
|
CliConfigInvalid {
|
|
probe_args: Vec<String>,
|
|
setup_copy: String,
|
|
/// One-line stderr excerpt identifying the parse error.
|
|
diagnostic: String,
|
|
},
|
|
/// Git for Windows is missing; open Agent runtimes for the installation guide.
|
|
GitBash,
|
|
}
|
|
|
|
impl RequirementPayload {
|
|
/// Human-readable instruction fragment for the nudge copy.
|
|
fn instruction(&self) -> String {
|
|
match self {
|
|
RequirementPayload::NormalizedField { field } => {
|
|
format!("set the **{}** field in Edit Agent dropdowns", field)
|
|
}
|
|
RequirementPayload::EnvKey { key } => {
|
|
format!("set `{}` in Edit Agent → Environment variables", key)
|
|
}
|
|
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 Agent runtimes in Settings to diagnose)",
|
|
harness
|
|
)
|
|
}
|
|
AcpAvailabilityStatus::AdapterOutdated => {
|
|
let harness = probe_args
|
|
.first()
|
|
.map(String::as_str)
|
|
.unwrap_or("the agent");
|
|
format!(
|
|
"reinstall the {} ACP adapter — the installed version is outdated (open Agent runtimes in Settings to diagnose)",
|
|
harness
|
|
)
|
|
}
|
|
AcpAvailabilityStatus::CliMissing => {
|
|
let harness = probe_args
|
|
.first()
|
|
.map(String::as_str)
|
|
.unwrap_or("the agent");
|
|
format!(
|
|
"install {} CLI (open Agent runtimes in Settings to diagnose)",
|
|
harness
|
|
)
|
|
}
|
|
AcpAvailabilityStatus::NotInstalled => {
|
|
let harness = probe_args
|
|
.first()
|
|
.map(String::as_str)
|
|
.unwrap_or("the agent");
|
|
format!(
|
|
"install {} (open Agent runtimes in Settings to diagnose)",
|
|
harness
|
|
)
|
|
}
|
|
},
|
|
RequirementPayload::CliConfigInvalid {
|
|
probe_args,
|
|
diagnostic,
|
|
..
|
|
} => {
|
|
let cli = probe_args.first().map(String::as_str).unwrap_or("the CLI");
|
|
let config_file = format!("~/.{}/config.toml", cli);
|
|
format!(
|
|
"{} is invalid: {} — fix the config and restart the agent",
|
|
config_file, diagnostic
|
|
)
|
|
}
|
|
RequirementPayload::GitBash => {
|
|
"install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The full setup payload passed by the desktop when spawning in setup mode.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub(crate) struct SetupPayload {
|
|
/// Human-readable agent display name (for the nudge message).
|
|
pub agent_name: String,
|
|
/// Hex-encoded agent pubkey. Carried so the desktop card can open
|
|
/// the Edit Agent dialog for this agent directly from the nudge.
|
|
pub agent_pubkey: String,
|
|
/// Surface-discriminated list of missing requirements.
|
|
pub requirements: Vec<RequirementPayload>,
|
|
}
|
|
|
|
impl SetupPayload {
|
|
/// Read and deserialize the setup payload from the env var, if present.
|
|
///
|
|
/// Returns `Ok(None)` when the env var is absent (normal mode).
|
|
/// Returns `Err` if the var is present but malformed.
|
|
pub(crate) fn from_env() -> Result<Option<Self>> {
|
|
Self::from_raw_env_value(std::env::var(SETUP_PAYLOAD_ENV_VAR).ok())
|
|
}
|
|
|
|
/// Parse an optional raw env-var value into a `SetupPayload`.
|
|
///
|
|
/// `None` or empty string → `Ok(None)` (normal mode, no setup payload).
|
|
/// Non-empty, valid JSON → `Ok(Some(payload))`.
|
|
/// Non-empty, malformed JSON → `Err`.
|
|
///
|
|
/// This is the pure core of `from_env()` and is the preferred target for
|
|
/// unit tests — it requires no global env mutation and is safe to call
|
|
/// concurrently.
|
|
pub(crate) fn from_raw_env_value(raw: Option<String>) -> Result<Option<Self>> {
|
|
let raw = match raw {
|
|
Some(v) if !v.is_empty() => v,
|
|
_ => return Ok(None),
|
|
};
|
|
let payload = serde_json::from_str::<Self>(&raw)
|
|
.map_err(|e| anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: {e}"))?;
|
|
Ok(Some(payload))
|
|
}
|
|
|
|
/// Build the nudge message body from the requirements.
|
|
///
|
|
/// The body contains two parts separated by a blank line:
|
|
/// 1. Human-readable markdown (unchanged; used by CLI and non-card clients).
|
|
/// 2. A fenced `buzz:config-nudge` sentinel block containing the structured
|
|
/// payload as JSON. The desktop client parses this block to render a
|
|
/// `ConfigNudgeCard`; clients that don't understand it see a code block.
|
|
fn nudge_body(&self) -> String {
|
|
let prose = if self.requirements.is_empty() {
|
|
format!(
|
|
"**{}** needs configuration before it can respond. Open Edit Agent to configure it.",
|
|
self.agent_name,
|
|
)
|
|
} else {
|
|
let steps: Vec<String> = self
|
|
.requirements
|
|
.iter()
|
|
.map(|r| format!("- {}", r.instruction()))
|
|
.collect();
|
|
|
|
let has_doctor_requirement = self
|
|
.requirements
|
|
.iter()
|
|
.any(|r| matches!(r, RequirementPayload::GitBash));
|
|
let all_external = self
|
|
.requirements
|
|
.iter()
|
|
.all(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. }));
|
|
let any_external = self
|
|
.requirements
|
|
.iter()
|
|
.any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. }));
|
|
|
|
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()
|
|
} 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()
|
|
} else {
|
|
// All Buzz-managed — original footer unchanged.
|
|
"Open Edit Agent in the Buzz app to set these.".to_string()
|
|
};
|
|
|
|
format!(
|
|
"**{}** needs configuration before it can respond:\n{}\n\n{}",
|
|
self.agent_name,
|
|
steps.join("\n"),
|
|
footer,
|
|
)
|
|
};
|
|
|
|
// SAFETY: `self` is fully `Serialize`; this can only fail if the types
|
|
// contain non-string map keys, which they don't — panic is acceptable.
|
|
let sentinel_json =
|
|
serde_json::to_string(self).expect("SetupPayload must be serializable to JSON");
|
|
|
|
format!("{}\n\n```buzz:config-nudge\n{}\n```", prose, sentinel_json)
|
|
}
|
|
}
|
|
|
|
// ── setup listener ────────────────────────────────────────────────────────────
|
|
|
|
/// Run the setup-mode event loop.
|
|
///
|
|
/// Connects to the relay, subscribes to channels, and responds to @mentions
|
|
/// of this agent with a surface-correct setup nudge. Never starts the agent
|
|
/// pool. Reconnects on relay close (mirroring normal mode) so the nudge
|
|
/// listener survives transient disconnects; `nudged_event_ids` deduplication
|
|
/// guards against replay on reconnect.
|
|
pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> Result<()> {
|
|
tracing::info!(
|
|
agent = %payload.agent_name,
|
|
requirements = payload.requirements.len(),
|
|
"buzz-acp entering setup mode"
|
|
);
|
|
|
|
let pubkey_hex = config.keys.public_key().to_hex();
|
|
|
|
// Parse BUZZ_AUTH_TAG for relay membership / NIP-OA.
|
|
let relay_auth_tag: Option<nostr::Tag> = std::env::var("BUZZ_AUTH_TAG")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok());
|
|
|
|
let startup_watermark: u64 = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let mut relay =
|
|
HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("setup-mode relay connect error: {e}"))?;
|
|
|
|
if let Err(e) = relay.set_startup_watermark(startup_watermark).await {
|
|
tracing::warn!("setup-mode: failed to set startup watermark: {e}");
|
|
}
|
|
|
|
relay
|
|
.subscribe_membership_notifications()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("setup-mode membership subscribe error: {e}"))?;
|
|
|
|
tracing::info!("setup-mode: connected and subscribed to membership notifications");
|
|
|
|
// Resolve owner for author-gate (same priority as normal mode).
|
|
let startup_owner = crate::resolve_agent_owner(&config);
|
|
let owner_cache = crate::OwnerCache::new(startup_owner);
|
|
|
|
// Discover channels and subscribe (using a "mentions" rule so we get
|
|
// notified when someone @-mentions the agent).
|
|
let channel_info_map = relay
|
|
.discover_channels()
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("setup-mode channel discovery error: {e}"))?;
|
|
|
|
tracing::info!(
|
|
"setup-mode: discovered {} channel(s)",
|
|
channel_info_map.len()
|
|
);
|
|
|
|
let channel_ids: Vec<Uuid> = channel_info_map.keys().copied().collect();
|
|
|
|
// Build subscription rules: mentions only (setup mode must not react to
|
|
// every message in a channel).
|
|
let rules = build_setup_subscription_rules(&config);
|
|
|
|
let channel_filters = crate::config::resolve_channel_filters(&config, &channel_ids, &rules);
|
|
|
|
if channel_filters.is_empty() {
|
|
tracing::warn!(
|
|
"setup-mode: no channel subscriptions resolved — nudge listener will sit idle"
|
|
);
|
|
}
|
|
|
|
for (channel_id, filter) in &channel_filters {
|
|
if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await {
|
|
tracing::warn!("setup-mode: failed to subscribe to channel {channel_id}: {e}");
|
|
} else {
|
|
tracing::info!("setup-mode: subscribed to channel {channel_id}");
|
|
}
|
|
}
|
|
|
|
let publisher = relay.event_publisher();
|
|
let rest_client = relay.rest_client();
|
|
|
|
let channel_info = crate::pool::ChannelInfoResolver::new(channel_info_map, rest_client.clone());
|
|
|
|
// Deduplicate by event-id so reconnect replay cannot double-nudge.
|
|
let mut nudged_event_ids: HashSet<EventId> = HashSet::new();
|
|
|
|
loop {
|
|
let Some(buzz_event) = relay.next_event().await else {
|
|
tracing::warn!("setup-mode: relay event stream ended — requesting reconnect");
|
|
if let Err(e) = relay.reconnect().await {
|
|
tracing::error!("setup-mode: relay background task is gone: {e} — exiting");
|
|
break;
|
|
}
|
|
continue;
|
|
};
|
|
|
|
let kind_u32 = buzz_event.event.kind.as_u16() as u32;
|
|
|
|
// Handle membership notifications so we subscribe to new channels
|
|
// and drop removed ones — no session/queue drain needed (no pool).
|
|
if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION
|
|
|| kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION
|
|
{
|
|
handle_setup_membership(&mut relay, &buzz_event, &config, &rules, &channel_ids).await;
|
|
continue;
|
|
}
|
|
|
|
// Ignore non-message kinds (relay housekeeping, etc.).
|
|
if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED {
|
|
continue;
|
|
}
|
|
|
|
// ignore_self: don't react to our own messages.
|
|
if buzz_event.event.pubkey.to_hex() == pubkey_hex {
|
|
continue;
|
|
}
|
|
|
|
// Require an explicit @mention of this agent — setup mode must not
|
|
// nudge on every channel event even if subscribe_mode is "all".
|
|
if !event_mentions_agent(&buzz_event.event, &pubkey_hex) {
|
|
continue;
|
|
}
|
|
|
|
// Apply the same author gate as normal mode so the nudge only goes
|
|
// to authors the real agent would have answered. Same DM hardening:
|
|
// in DMs only owner/siblings get a nudge (fail-closed on unknown type).
|
|
let author_hex = buzz_event.event.pubkey.to_hex();
|
|
let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await;
|
|
let allowed = author_allowed(
|
|
&config.respond_to,
|
|
&config.respond_to_allowlist,
|
|
&author_hex,
|
|
is_dm,
|
|
&owner_cache,
|
|
&rest_client,
|
|
)
|
|
.await;
|
|
|
|
// Apply channel/kind filter rules.
|
|
let filter_matched = filter::match_event(
|
|
&buzz_event.event,
|
|
buzz_event.channel_id,
|
|
&rules,
|
|
&pubkey_hex,
|
|
)
|
|
.await
|
|
.is_some();
|
|
|
|
// Pure gate: author gate verdict + event-id dedup.
|
|
if !should_nudge_for_event(
|
|
buzz_event.event.id,
|
|
allowed,
|
|
filter_matched,
|
|
&mut nudged_event_ids,
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Build and publish the setup nudge.
|
|
if let Err(e) = publish_setup_nudge(
|
|
&publisher,
|
|
&config.keys,
|
|
buzz_event.channel_id,
|
|
&buzz_event.event,
|
|
&payload,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("setup-mode: failed to publish nudge: {e}");
|
|
} else {
|
|
tracing::info!(
|
|
channel_id = %buzz_event.channel_id,
|
|
event_id = %buzz_event.event.id,
|
|
"setup-mode: nudge published"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Outcome of the pure per-event gate checks in setup mode.
|
|
///
|
|
/// Callers compute the async gates (`author_allowed`, `filter::match_event`)
|
|
/// up-front, then pass the boolean results here. This helper handles
|
|
/// everything that is synchronous and stateful: the author gate verdict
|
|
/// and event-id dedup.
|
|
///
|
|
/// Returns `true` when the event should produce a nudge.
|
|
#[must_use]
|
|
pub(crate) fn should_nudge_for_event(
|
|
event_id: EventId,
|
|
author_allowed: bool,
|
|
filter_matched: bool,
|
|
nudged_event_ids: &mut HashSet<EventId>,
|
|
) -> bool {
|
|
if !author_allowed {
|
|
tracing::debug!("setup-mode: event filtered by author gate");
|
|
return false;
|
|
}
|
|
if !filter_matched {
|
|
return false;
|
|
}
|
|
if !nudged_event_ids.insert(event_id) {
|
|
tracing::debug!(%event_id, "setup-mode: skipping already-nudged event");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Build the subscription rules used in setup mode.
|
|
///
|
|
/// Always uses "mentions" mode: setup mode must not react to every event.
|
|
/// Even if the agent's normal config is `subscribe_mode = all`, setup mode
|
|
/// enforces explicit @mention filtering (see `event_mentions_agent` call in
|
|
/// the loop above).
|
|
fn build_setup_subscription_rules(config: &Config) -> Vec<filter::SubscriptionRule> {
|
|
use crate::config::SubscribeMode;
|
|
|
|
let kinds = config
|
|
.kinds_override
|
|
.clone()
|
|
.unwrap_or_else(|| vec![KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED]);
|
|
|
|
match &config.subscribe_mode {
|
|
// Config mode: load the actual rules, but they will be filtered by
|
|
// the explicit event_mentions_agent check in the main loop anyway.
|
|
SubscribeMode::Config => match crate::config::load_rules(&config.config_path) {
|
|
Ok(rules) => rules,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"setup-mode: could not load config rules ({e}); falling back to mentions"
|
|
);
|
|
vec![mentions_rule(kinds)]
|
|
}
|
|
},
|
|
_ => vec![mentions_rule(kinds)],
|
|
}
|
|
}
|
|
|
|
fn mentions_rule(kinds: Vec<u32>) -> filter::SubscriptionRule {
|
|
use std::sync::{atomic::AtomicU32, Arc};
|
|
filter::SubscriptionRule {
|
|
name: "setup-mentions".into(),
|
|
channels: filter::ChannelScope::All("all".into()),
|
|
kinds,
|
|
require_mention: true,
|
|
filter: None,
|
|
compiled_filter: None,
|
|
consecutive_timeouts: Arc::new(AtomicU32::new(0)),
|
|
prompt_tag: Some("@mention".into()),
|
|
}
|
|
}
|
|
|
|
/// Handle membership add/remove events in setup mode.
|
|
///
|
|
/// Subscribe new channels; unsubscribe removed ones. No queue/session
|
|
/// teardown — there is no pool.
|
|
async fn handle_setup_membership(
|
|
relay: &mut HarnessRelay,
|
|
buzz_event: &crate::relay::BuzzEvent,
|
|
config: &Config,
|
|
rules: &[filter::SubscriptionRule],
|
|
_initial_channel_ids: &[Uuid],
|
|
) {
|
|
let kind_u32 = buzz_event.event.kind.as_u16() as u32;
|
|
let channel_id = buzz_event.channel_id;
|
|
|
|
if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION {
|
|
// Subscribe to the newly-joined channel.
|
|
let ids = vec![channel_id];
|
|
let filters = crate::config::resolve_channel_filters(config, &ids, rules);
|
|
for (cid, filter) in filters {
|
|
if let Err(e) = relay.subscribe_channel(cid, filter).await {
|
|
tracing::warn!("setup-mode: failed to subscribe to new channel {cid}: {e}");
|
|
} else {
|
|
tracing::info!("setup-mode: subscribed to new channel {cid}");
|
|
}
|
|
}
|
|
} else if kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION {
|
|
if let Err(e) = relay.unsubscribe_channel(channel_id).await {
|
|
tracing::warn!("setup-mode: failed to unsubscribe from channel {channel_id}: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build and publish a setup nudge reply to the triggering event.
|
|
///
|
|
/// Threading: flat reply to the thread root if one exists; otherwise reply
|
|
/// to the triggering event itself. P-tags the asker.
|
|
async fn publish_setup_nudge(
|
|
publisher: &RelayEventPublisher,
|
|
keys: &nostr::Keys,
|
|
channel_id: Uuid,
|
|
triggering_event: &nostr::Event,
|
|
payload: &SetupPayload,
|
|
) -> Result<()> {
|
|
use buzz_sdk::ThreadRef;
|
|
|
|
// Parse NIP-10 thread tags to determine reply target.
|
|
let thread_tags = crate::queue::parse_thread_tags(triggering_event);
|
|
|
|
let thread_ref = if let Some(root_str) = &thread_tags.root_event_id {
|
|
// Threaded event: reply flat to the root.
|
|
let root_id = nostr::EventId::from_hex(root_str)
|
|
.map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?;
|
|
Some(ThreadRef {
|
|
root_event_id: root_id,
|
|
parent_event_id: root_id,
|
|
})
|
|
} else {
|
|
// Top-level event: reply to the triggering event.
|
|
Some(ThreadRef {
|
|
root_event_id: triggering_event.id,
|
|
parent_event_id: triggering_event.id,
|
|
})
|
|
};
|
|
|
|
let body = payload.nudge_body();
|
|
let author_hex = triggering_event.pubkey.to_hex();
|
|
|
|
let event_builder = buzz_sdk::build_message(
|
|
channel_id,
|
|
&body,
|
|
thread_ref.as_ref(),
|
|
&[&author_hex], // p-tag the asker
|
|
false,
|
|
&[],
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?;
|
|
|
|
let signed = event_builder
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| anyhow::anyhow!("failed to sign setup nudge: {e}"))?;
|
|
|
|
publisher
|
|
.publish_event(signed)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("failed to publish setup nudge: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn setup_payload_from_raw_returns_none_when_absent() {
|
|
// None → Ok(None): normal startup, no setup payload.
|
|
let result = SetupPayload::from_raw_env_value(None).unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn setup_payload_from_raw_returns_none_on_empty_string() {
|
|
// Empty string → Ok(None): treated the same as absent.
|
|
let result = SetupPayload::from_raw_env_value(Some(String::new())).unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn setup_payload_from_raw_returns_err_on_malformed_json() {
|
|
// Malformed JSON → Err: no global env mutation, safe to run concurrently.
|
|
let result = SetupPayload::from_raw_env_value(Some("not-valid-json{{{".into()));
|
|
assert!(result.is_err(), "malformed JSON must return Err");
|
|
}
|
|
|
|
#[test]
|
|
fn setup_payload_deserializes_correctly() {
|
|
let json = r#"{
|
|
"agent_name": "Fizz",
|
|
"agent_pubkey": "aabbccddeeff0011",
|
|
"requirements": [
|
|
{"surface": "normalized_field", "field": "provider"},
|
|
{"surface": "env_key", "key": "ANTHROPIC_API_KEY"}
|
|
]
|
|
}"#;
|
|
let payload: SetupPayload = serde_json::from_str(json).unwrap();
|
|
assert_eq!(payload.agent_name, "Fizz");
|
|
assert_eq!(payload.requirements.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_payload_deserializes_git_bash_requirement() {
|
|
let payload: SetupPayload = serde_json::from_str(
|
|
r#"{"agent_name":"Buzz Agent","agent_pubkey":"test","requirements":[{"surface":"git_bash"}]}"#,
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
payload.requirements.as_slice(),
|
|
[RequirementPayload::GitBash]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_names_all_requirements() {
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![
|
|
RequirementPayload::NormalizedField {
|
|
field: "provider".to_string(),
|
|
},
|
|
RequirementPayload::EnvKey {
|
|
key: "ANTHROPIC_API_KEY".to_string(),
|
|
},
|
|
],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
body.contains("provider"),
|
|
"nudge body should mention the missing provider field"
|
|
);
|
|
assert!(
|
|
body.contains("ANTHROPIC_API_KEY"),
|
|
"nudge body should mention the missing env key"
|
|
);
|
|
assert!(body.contains("Fizz"), "nudge body should name the agent");
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_codex_copy_does_not_mention_openai_api_key() {
|
|
let payload = SetupPayload {
|
|
agent_name: "Codex".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![RequirementPayload::CliLogin {
|
|
probe_args: vec![
|
|
"codex".to_string(),
|
|
"login".to_string(),
|
|
"status".to_string(),
|
|
],
|
|
setup_copy: "run `codex login`".to_string(),
|
|
availability: AcpAvailabilityStatus::Available,
|
|
}],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
!body.contains("OPENAI_API_KEY"),
|
|
"codex nudge must not mention OPENAI_API_KEY; got: {body:?}"
|
|
);
|
|
assert!(
|
|
body.contains("codex login"),
|
|
"codex nudge must mention `codex login`; got: {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_runtime_install_copy_points_to_agent_runtimes() {
|
|
for availability in [
|
|
AcpAvailabilityStatus::AdapterMissing,
|
|
AcpAvailabilityStatus::AdapterOutdated,
|
|
AcpAvailabilityStatus::CliMissing,
|
|
AcpAvailabilityStatus::NotInstalled,
|
|
] {
|
|
let payload = SetupPayload {
|
|
agent_name: "Codex".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![RequirementPayload::CliLogin {
|
|
probe_args: vec!["codex".to_string()],
|
|
setup_copy: "run `codex login`".to_string(),
|
|
availability,
|
|
}],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
body.contains("Agent runtimes in Settings"),
|
|
"runtime install nudge must point to Agent runtimes; got: {body:?}"
|
|
);
|
|
assert!(
|
|
!body.contains("Doctor"),
|
|
"runtime install nudge must not point to the removed Doctor section; got: {body:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_git_bash_copy_points_to_agent_runtimes() {
|
|
let payload = SetupPayload {
|
|
agent_name: "Buzz Agent".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![RequirementPayload::GitBash],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
body.contains("Open Agent runtimes in Settings"),
|
|
"Git Bash nudge must point to Agent runtimes; got: {body:?}"
|
|
);
|
|
assert!(
|
|
!body.contains("Doctor"),
|
|
"Git Bash nudge must not point to the removed Doctor section; got: {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_empty_requirements_falls_back_to_generic() {
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(body.contains("Fizz"));
|
|
assert!(body.contains("needs configuration"));
|
|
}
|
|
|
|
// ── config-invalid footer tests ────────────────────────────────────────────
|
|
|
|
fn make_cli_config_invalid(cli: &str, diagnostic: &str) -> RequirementPayload {
|
|
RequirementPayload::CliConfigInvalid {
|
|
probe_args: vec![cli.to_string()],
|
|
setup_copy: String::new(),
|
|
diagnostic: diagnostic.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_all_config_invalid_omits_edit_agent_footer() {
|
|
// An all-CliConfigInvalid requirements list must NOT send users to
|
|
// Edit Agent (which cannot fix an external config file).
|
|
let payload = SetupPayload {
|
|
agent_name: "Codex".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![make_cli_config_invalid(
|
|
"codex",
|
|
"unknown variant `ultra` for field `model_reasoning_effort`",
|
|
)],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
!body.contains("Open Edit Agent"),
|
|
"all-config-invalid nudge must not mention Open Edit Agent; got: {body:?}"
|
|
);
|
|
assert!(
|
|
body.contains("config.toml"),
|
|
"nudge must name the config file; got: {body:?}"
|
|
);
|
|
assert!(
|
|
body.contains("restart the agent"),
|
|
"nudge must include restart guidance; got: {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_mixed_requirements_uses_split_footer() {
|
|
// Mixed list: one Buzz-managed env key + one external config invalid.
|
|
// Footer must address both sides.
|
|
let payload = SetupPayload {
|
|
agent_name: "Codex".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![
|
|
RequirementPayload::EnvKey {
|
|
key: "SOME_API_KEY".to_string(),
|
|
},
|
|
make_cli_config_invalid("codex", "unknown variant `gpt-5.6-sol`"),
|
|
],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
body.contains("Open Edit Agent"),
|
|
"mixed nudge must mention Open Edit Agent for managed fields; got: {body:?}"
|
|
);
|
|
assert!(
|
|
body.contains("fix the external CLI config"),
|
|
"mixed nudge must mention fixing the external config; got: {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_all_buzz_managed_retains_original_footer() {
|
|
// Pure Buzz-managed requirements → original "Open Edit Agent" footer unchanged.
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![RequirementPayload::EnvKey {
|
|
key: "ANTHROPIC_API_KEY".to_string(),
|
|
}],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
body.contains("Open Edit Agent in the Buzz app to set these."),
|
|
"all-managed nudge must use the original Edit Agent footer; got: {body:?}"
|
|
);
|
|
}
|
|
|
|
// ── sentinel block tests ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn nudge_body_contains_sentinel_block() {
|
|
// The body must end with a ```buzz:config-nudge fence so the desktop
|
|
// can detect and strip it before rendering the ConfigNudgeCard.
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![RequirementPayload::EnvKey {
|
|
key: "ANTHROPIC_API_KEY".to_string(),
|
|
}],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(
|
|
body.contains("```buzz:config-nudge\n"),
|
|
"body must open the sentinel fence; got: {body:?}"
|
|
);
|
|
assert!(
|
|
body.ends_with("```"),
|
|
"body must close the sentinel fence; got: {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_sentinel_round_trips_payload() {
|
|
// The JSON inside the sentinel block must deserialize back to an
|
|
// equivalent SetupPayload (same agent_name and requirements).
|
|
let payload = SetupPayload {
|
|
agent_name: "Atlas".to_string(),
|
|
agent_pubkey: "ddeeff00".to_string(),
|
|
requirements: vec![
|
|
RequirementPayload::NormalizedField {
|
|
field: "model".to_string(),
|
|
},
|
|
RequirementPayload::EnvKey {
|
|
key: "OPENAI_API_KEY".to_string(),
|
|
},
|
|
RequirementPayload::CliLogin {
|
|
probe_args: vec!["codex".to_string(), "login".to_string()],
|
|
setup_copy: "run `codex login`".to_string(),
|
|
availability: AcpAvailabilityStatus::Available,
|
|
},
|
|
],
|
|
};
|
|
let body = payload.nudge_body();
|
|
|
|
// Extract the JSON between the fence markers.
|
|
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;
|
|
let json = &body[start..end];
|
|
|
|
let recovered: SetupPayload =
|
|
serde_json::from_str(json).expect("sentinel JSON must deserialize");
|
|
assert_eq!(recovered.agent_name, payload.agent_name);
|
|
assert_eq!(recovered.agent_pubkey, payload.agent_pubkey);
|
|
assert_eq!(recovered.requirements.len(), payload.requirements.len());
|
|
}
|
|
|
|
#[test]
|
|
fn nudge_body_prose_still_present_with_sentinel() {
|
|
// Existing prose checks must pass — the sentinel is APPENDED, not a
|
|
// replacement, so all prior `body.contains(...)` invariants hold.
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".to_string(),
|
|
agent_pubkey: "test".to_string(),
|
|
requirements: vec![
|
|
RequirementPayload::NormalizedField {
|
|
field: "provider".to_string(),
|
|
},
|
|
RequirementPayload::EnvKey {
|
|
key: "ANTHROPIC_API_KEY".to_string(),
|
|
},
|
|
],
|
|
};
|
|
let body = payload.nudge_body();
|
|
assert!(body.contains("provider"), "prose must name the field");
|
|
assert!(
|
|
body.contains("ANTHROPIC_API_KEY"),
|
|
"prose must name the key"
|
|
);
|
|
assert!(body.contains("Fizz"), "prose must name the agent");
|
|
// Sentinel is also present.
|
|
assert!(
|
|
body.contains("```buzz:config-nudge"),
|
|
"sentinel must follow"
|
|
);
|
|
}
|
|
|
|
// ── should_nudge_for_event gate tests ─────────────────────────────────────
|
|
//
|
|
// These tests exercise the loop-wiring for the two safety-critical guards:
|
|
// (a) non-allowlisted author → no nudge, (b) same event-id → exactly one
|
|
// nudge. They use the extracted `should_nudge_for_event` helper, which is
|
|
// the exact code the live loop calls.
|
|
|
|
fn fake_event_id(byte: u8) -> EventId {
|
|
EventId::from_byte_array([byte; 32])
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_allowlisted_author_returns_no_nudge() {
|
|
// author_allowed = false → should return false regardless of other args.
|
|
let mut dedup: HashSet<EventId> = HashSet::new();
|
|
let event_id = fake_event_id(0xAA);
|
|
|
|
let result = should_nudge_for_event(
|
|
event_id, false, // author NOT allowed
|
|
true, // filter matched — would otherwise nudge
|
|
&mut dedup,
|
|
);
|
|
|
|
assert!(!result, "non-allowlisted author must not produce a nudge");
|
|
// Dedup set must remain empty — no phantom insertion for blocked author.
|
|
assert!(
|
|
dedup.is_empty(),
|
|
"dedup set must not record event for blocked author"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_same_event_id_twice_nudges_exactly_once() {
|
|
// The first call with a given event-id should return true; the second
|
|
// call with the identical id must return false (replay dedup).
|
|
let mut dedup: HashSet<EventId> = HashSet::new();
|
|
let event_id = fake_event_id(0xBB);
|
|
|
|
let first = should_nudge_for_event(
|
|
event_id, true, // allowed
|
|
true, // matched
|
|
&mut dedup,
|
|
);
|
|
assert!(first, "first occurrence must be accepted");
|
|
|
|
// Simulate reconnect replay: same event arrives again.
|
|
let second = should_nudge_for_event(
|
|
event_id, true, // allowed
|
|
true, // matched
|
|
&mut dedup,
|
|
);
|
|
assert!(
|
|
!second,
|
|
"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:?}"
|
|
);
|
|
}
|
|
}
|