mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
🤖 ## Summary Today an agent does not wake up when someone edits an existing message to @mention it. With this PR, adding the mention in an edit is enough to reach the agent. The agent then works on the original message, as #4741 set up. - Agents now receive message edits by default. Before, they subscribed only to new messages (and a few other event types). - The desktop app puts a mention tag on an edit only for people whom that edit newly mentions. So an agent wakes once, for the new mention. It does not wake again when the same message is edited later. This PR also fixes one case in the desktop app: when the original also mentioned someone whose profile was not loaded, every mention counted as new, so a typo fix woke the agent again. Now the recipients that the app knows from the original always count as already notified. - This default applies in normal mode, to channels the agent joins while it is running, and in setup mode. - If you give an explicit `--kinds` list, it is used without change. - Sessions do not change in this PR. An edit uses the session that #4741 chooses: with per-thread sessions, the original message's thread; with channel sessions, the channel. ## Details - The change adds the edit event kind (40003) to one shared default list. The startup rules, the relay subscription filters, and the setup rules all use that list. - An agent accepts an event only if both the relay subscription and its local rules allow it. The normal listener's startup rules now come from one function, `startup_subscription_rules`. The harness and the tests both call it, so the tests check the rules that actually run. ### Stack **2 of 3**, based on #4741. Next: #6132. ### Related issue Fixes #2540. ### Testing - `cargo test -p buzz-acp --all-targets` passes. The one test that fails when the git setting `nostr.keyfile` is set is described in #4741. - Admission tests run on the rules the harness installs, for the normal listener and for setup mode. An edit that mentions the agent is accepted. An edit that does not mention it is rejected. - I removed kind 40003 from each of the two defaults, one at a time. Each time, these tests failed. - Config tests check that edits are in the default kinds for startup channels and for channels joined later. - Desktop regression: `@Agent @Missing User please chek` edited to `check`, with only the agent's profile loaded, sends no new notification. It fails on the old code. --------- Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
1499 lines
57 KiB
Rust
1499 lines
57 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_STREAM_MESSAGE_EDIT, 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::{
|
|
config::Config,
|
|
event_mentions_agent, filter,
|
|
inbound_author_gate::AuthorizedListenerEvent,
|
|
relay::{self, HarnessRelay, RelayEventPublisher},
|
|
InboundAuthorGate, OwnerCache,
|
|
};
|
|
|
|
// ── 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,
|
|
/// A custom harness command could not be resolved in the current PATH.
|
|
MissingBinary { command: String },
|
|
}
|
|
|
|
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()
|
|
}
|
|
RequirementPayload::MissingBinary { command } => {
|
|
format!("install `{command}` or add it to PATH")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 all_missing_binary = self
|
|
.requirements
|
|
.iter()
|
|
.all(|r| matches!(r, RequirementPayload::MissingBinary { .. }));
|
|
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_missing_binary {
|
|
"Install the missing binary or update PATH, then restart Buzz.".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");
|
|
|
|
let rest_client = relay.rest_client();
|
|
let mut author_gate_ctx =
|
|
crate::InboundAuthorGate::connect(&rest_client, &pubkey_hex, "setup startup").await;
|
|
|
|
// 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 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;
|
|
}
|
|
|
|
if !setup_listener_admits(&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 Some(authorized_event) = authorize_setup_listener_event(
|
|
&mut author_gate_ctx,
|
|
buzz_event,
|
|
&config.respond_to,
|
|
&config.respond_to_allowlist,
|
|
&owner_cache,
|
|
&channel_info,
|
|
&rest_client,
|
|
)
|
|
.await
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if !nudge_authorized_event(
|
|
authorized_event,
|
|
&rules,
|
|
&pubkey_hex,
|
|
&mut nudged_event_ids,
|
|
&publisher,
|
|
&rest_client,
|
|
&config.keys,
|
|
&payload,
|
|
)
|
|
.await
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Pre-authorization listener gate: nudge-eligible kind, not our own event,
|
|
/// and an explicit @mention of this agent. Setup mode must not nudge on every
|
|
/// channel event even if `subscribe_mode` is `all`.
|
|
///
|
|
/// Kind:40003 edits are eligible because a delivered edit carries `p` tags
|
|
/// only for recipients it newly mentions; its nudge follows the original
|
|
/// message (see [`publish_setup_nudge`]).
|
|
fn setup_listener_admits(event: &nostr::Event, pubkey_hex: &str) -> bool {
|
|
let kind = u32::from(event.kind.as_u16());
|
|
if !matches!(
|
|
kind,
|
|
KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_EDIT | KIND_WORKFLOW_APPROVAL_REQUESTED
|
|
) {
|
|
return false;
|
|
}
|
|
event.pubkey.to_hex() != pubkey_hex && event_mentions_agent(event, pubkey_hex)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn nudge_authorized_event(
|
|
authorized_event: AuthorizedListenerEvent,
|
|
rules: &[filter::SubscriptionRule],
|
|
pubkey_hex: &str,
|
|
nudged_event_ids: &mut HashSet<EventId>,
|
|
publisher: &RelayEventPublisher,
|
|
rest_client: &relay::RestClient,
|
|
keys: &nostr::Keys,
|
|
payload: &SetupPayload,
|
|
) -> bool {
|
|
let (buzz_event, effective_author) = authorized_event.into_parts();
|
|
|
|
// Apply channel/kind filter rules.
|
|
let filter_matched =
|
|
filter::match_event(&buzz_event.event, buzz_event.channel_id, rules, pubkey_hex)
|
|
.await
|
|
.is_some();
|
|
|
|
if !should_nudge_for_event(buzz_event.event.id, filter_matched, nudged_event_ids) {
|
|
return false;
|
|
}
|
|
|
|
// An edit's nudge follows its original message; resolve it with the same
|
|
// bounded, scoped lookup normal mode uses at admission.
|
|
let edit =
|
|
crate::edit_routing::resolve_edit(&buzz_event.event, buzz_event.channel_id, rest_client)
|
|
.await;
|
|
|
|
// Build and publish the setup nudge.
|
|
if let Err(e) = publish_setup_nudge(
|
|
publisher,
|
|
keys,
|
|
buzz_event.channel_id,
|
|
&buzz_event.event,
|
|
edit.as_ref(),
|
|
&effective_author,
|
|
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"
|
|
);
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(super) async fn authorize_setup_listener_event(
|
|
author_gate: &mut InboundAuthorGate,
|
|
buzz_event: relay::BuzzEvent,
|
|
respond_to: &crate::config::RespondTo,
|
|
allowlist: &HashSet<String>,
|
|
owner_cache: &OwnerCache,
|
|
channel_info: &crate::pool::ChannelInfoResolver,
|
|
rest_client: &relay::RestClient,
|
|
) -> Option<AuthorizedListenerEvent> {
|
|
author_gate
|
|
.authorize_listener_event(
|
|
buzz_event,
|
|
respond_to,
|
|
allowlist,
|
|
owner_cache,
|
|
channel_info,
|
|
rest_client,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Outcome of the synchronous per-event setup checks.
|
|
///
|
|
/// This helper owns only filter matching and event-id deduplication; the
|
|
/// production path can call it only through `nudge_authorized_event`, whose
|
|
/// input is the gate's private authorized capability.
|
|
///
|
|
/// Returns `true` when the event should produce a nudge.
|
|
#[must_use]
|
|
pub(crate) fn should_nudge_for_event(
|
|
event_id: EventId,
|
|
filter_matched: bool,
|
|
nudged_event_ids: &mut HashSet<EventId>,
|
|
) -> bool {
|
|
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(crate::config::default_mention_kinds);
|
|
|
|
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. An edit routes through its original
|
|
/// message (`edit`, resolved by the caller): the original's thread root, or
|
|
/// the original itself when top-level — never the auxiliary edit event. An
|
|
/// unresolved original is never claimed as a thread root, because the relay
|
|
/// rejects a root that does not match the original's real ancestry; that
|
|
/// nudge posts at top level. P-tags the verified effective asker.
|
|
async fn publish_setup_nudge(
|
|
publisher: &RelayEventPublisher,
|
|
keys: &nostr::Keys,
|
|
channel_id: Uuid,
|
|
triggering_event: &nostr::Event,
|
|
edit: Option<&crate::queue::ResolvedEdit>,
|
|
recipient_hex: &str,
|
|
payload: &SetupPayload,
|
|
) -> Result<()> {
|
|
let signed = build_setup_nudge_event(
|
|
keys,
|
|
channel_id,
|
|
triggering_event,
|
|
edit,
|
|
recipient_hex,
|
|
payload,
|
|
)?;
|
|
publisher
|
|
.publish_event(signed)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("failed to publish setup nudge: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Build the signed nudge published by [`publish_setup_nudge`].
|
|
fn build_setup_nudge_event(
|
|
keys: &nostr::Keys,
|
|
channel_id: Uuid,
|
|
triggering_event: &nostr::Event,
|
|
edit: Option<&crate::queue::ResolvedEdit>,
|
|
recipient_hex: &str,
|
|
payload: &SetupPayload,
|
|
) -> Result<nostr::Event> {
|
|
let unresolved_edit =
|
|
crate::queue::edit_target_id(triggering_event).is_some() && edit.is_none();
|
|
let thread_ref = if unresolved_edit {
|
|
None
|
|
} else {
|
|
let anchor = crate::queue::routing_thread_tags(triggering_event, edit)
|
|
.root_event_id
|
|
.unwrap_or_else(|| crate::queue::reaction_target_id(triggering_event));
|
|
let anchor_id = nostr::EventId::from_hex(&anchor)
|
|
.map_err(|e| anyhow::anyhow!("invalid nudge anchor event id: {e}"))?;
|
|
Some(buzz_sdk::ThreadRef {
|
|
root_event_id: anchor_id,
|
|
parent_event_id: anchor_id,
|
|
})
|
|
};
|
|
|
|
let body = payload.nudge_body();
|
|
|
|
let event_builder = buzz_sdk::build_message(
|
|
channel_id,
|
|
&body,
|
|
thread_ref.as_ref(),
|
|
&[recipient_hex], // p-tag the verified effective asker
|
|
false,
|
|
&[],
|
|
&[],
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?;
|
|
|
|
event_builder
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| anyhow::anyhow!("failed to sign setup nudge: {e}"))
|
|
}
|
|
|
|
// ── 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 setup_payload_deserializes_missing_binary_requirement() {
|
|
let payload = SetupPayload::from_raw_env_value(Some(
|
|
r#"{"agent_name":"Carol","agent_pubkey":"test","requirements":[{"surface":"missing_binary","command":"buzz-pi-acp"}]}"#.to_string(),
|
|
))
|
|
.unwrap()
|
|
.expect("missing_binary payload must parse");
|
|
assert!(matches!(
|
|
payload.requirements.as_slice(),
|
|
[RequirementPayload::MissingBinary { command }] if command == "buzz-pi-acp"
|
|
));
|
|
let body = payload.nudge_body();
|
|
assert!(body.contains("install `buzz-pi-acp` or add it to PATH"));
|
|
assert!(body.contains("restart Buzz"));
|
|
assert!(!body.contains("Open Edit Agent"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authorized_workflow_nudge_mentions_effective_owner_not_relay_signer() {
|
|
let agent_keys = nostr::Keys::generate();
|
|
let relay_keys = nostr::Keys::generate();
|
|
let workflow_owner = nostr::Keys::generate().public_key().to_hex();
|
|
let agent = nostr::Keys::generate().public_key().to_hex();
|
|
let channel_id = Uuid::new_v4();
|
|
let event = relay::BuzzEvent {
|
|
connection_generation: 0,
|
|
channel_id,
|
|
event: crate::author_gate_tests::relay_signed_workflow_dispatch(
|
|
&relay_keys,
|
|
&workflow_owner,
|
|
&agent,
|
|
),
|
|
};
|
|
let relay_hex = relay_keys.public_key().to_hex();
|
|
let (rest_client, server) =
|
|
crate::author_gate_tests::nip11_server(serde_json::json!({ "self": relay_hex })).await;
|
|
let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "setup nudge test").await;
|
|
let owner_cache = OwnerCache::new(Some(workflow_owner.clone()));
|
|
let channel_info = crate::pool::ChannelInfoResolver::new(
|
|
std::collections::HashMap::from([(
|
|
channel_id,
|
|
relay::ChannelInfo {
|
|
name: "workflow".into(),
|
|
channel_type: "stream".into(),
|
|
description: None,
|
|
},
|
|
)]),
|
|
rest_client.clone(),
|
|
);
|
|
let authorized = authorize_setup_listener_event(
|
|
&mut gate,
|
|
event,
|
|
&crate::config::RespondTo::OwnerOnly,
|
|
&HashSet::new(),
|
|
&owner_cache,
|
|
&channel_info,
|
|
&rest_client,
|
|
)
|
|
.await
|
|
.expect("workflow owner should pass the setup author gate");
|
|
let rules = vec![filter::SubscriptionRule {
|
|
name: "workflow".into(),
|
|
channels: filter::ChannelScope::All("all".into()),
|
|
..Default::default()
|
|
}];
|
|
let (publisher, mut published) = RelayEventPublisher::test_pair();
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".into(),
|
|
agent_pubkey: agent.clone(),
|
|
requirements: vec![],
|
|
};
|
|
|
|
assert!(
|
|
nudge_authorized_event(
|
|
authorized,
|
|
&rules,
|
|
&agent,
|
|
&mut HashSet::new(),
|
|
&publisher,
|
|
&rest_client,
|
|
&agent_keys,
|
|
&payload,
|
|
)
|
|
.await
|
|
);
|
|
let nudge = published.recv().await.expect("setup nudge published");
|
|
let recipients: Vec<&str> = nudge
|
|
.tags
|
|
.iter()
|
|
.filter_map(|tag| {
|
|
let values = tag.as_slice();
|
|
(values.first().map(String::as_str) == Some("p"))
|
|
.then(|| values.get(1).map(String::as_str))
|
|
.flatten()
|
|
})
|
|
.collect();
|
|
assert!(recipients.contains(&workflow_owner.as_str()));
|
|
assert!(!recipients.contains(&relay_hex.as_str()));
|
|
server.abort();
|
|
}
|
|
|
|
fn nudge_e_tags(event: &nostr::Event) -> Vec<Vec<String>> {
|
|
event
|
|
.tags
|
|
.iter()
|
|
.map(|tag| tag.as_slice().to_vec())
|
|
.filter(|tag| tag.first().map(String::as_str) == Some("e"))
|
|
.collect()
|
|
}
|
|
|
|
/// Drive a mentioned kind:40003 edit through the production listener
|
|
/// seams (pre-gate, author gate, `nudge_authorized_event`) against a relay
|
|
/// that serves `original_response` for the edit-original lookup, and
|
|
/// return the published nudge.
|
|
async fn nudge_for_edit(
|
|
edit_target: &str,
|
|
original_response: serde_json::Value,
|
|
) -> (nostr::Event, nostr::Event) {
|
|
let agent_keys = nostr::Keys::generate();
|
|
let agent = agent_keys.public_key().to_hex();
|
|
let asker = nostr::Keys::generate();
|
|
let channel_id = Uuid::new_v4();
|
|
let edit = nostr::EventBuilder::new(
|
|
nostr::Kind::Custom(KIND_STREAM_MESSAGE_EDIT as u16),
|
|
"edited to mention the agent",
|
|
)
|
|
.tags([
|
|
nostr::Tag::parse(["e", edit_target]).unwrap(),
|
|
nostr::Tag::parse(["p", &agent]).unwrap(),
|
|
nostr::Tag::parse(["h", &channel_id.to_string()]).unwrap(),
|
|
])
|
|
.sign_with_keys(&asker)
|
|
.unwrap();
|
|
assert!(
|
|
setup_listener_admits(&edit, &agent),
|
|
"listener gate must admit a mentioned edit"
|
|
);
|
|
|
|
let (gate_rest, gate_server) =
|
|
crate::author_gate_tests::nip11_server(serde_json::json!({})).await;
|
|
let (query_rest, query_server) =
|
|
crate::author_gate_tests::nip11_server(original_response).await;
|
|
let mut gate = InboundAuthorGate::connect(&gate_rest, &agent, "setup edit test").await;
|
|
let channel_info = crate::pool::ChannelInfoResolver::new(
|
|
std::collections::HashMap::from([(
|
|
channel_id,
|
|
relay::ChannelInfo {
|
|
name: "general".into(),
|
|
channel_type: "stream".into(),
|
|
description: None,
|
|
},
|
|
)]),
|
|
gate_rest.clone(),
|
|
);
|
|
let authorized = authorize_setup_listener_event(
|
|
&mut gate,
|
|
relay::BuzzEvent {
|
|
connection_generation: 0,
|
|
channel_id,
|
|
event: edit.clone(),
|
|
},
|
|
&crate::config::RespondTo::Anyone,
|
|
&HashSet::new(),
|
|
&OwnerCache::new(None),
|
|
&channel_info,
|
|
&gate_rest,
|
|
)
|
|
.await
|
|
.expect("asker should pass the setup author gate");
|
|
// Production setup rules with default kinds, so dropping kind:40003
|
|
// from the setup admission path fails these tests.
|
|
let rules = default_setup_rules();
|
|
let (publisher, mut published) = RelayEventPublisher::test_pair();
|
|
let payload = SetupPayload {
|
|
agent_name: "Fizz".into(),
|
|
agent_pubkey: agent.clone(),
|
|
requirements: vec![],
|
|
};
|
|
assert!(
|
|
nudge_authorized_event(
|
|
authorized,
|
|
&rules,
|
|
&agent,
|
|
&mut HashSet::new(),
|
|
&publisher,
|
|
&query_rest,
|
|
&agent_keys,
|
|
&payload,
|
|
)
|
|
.await
|
|
);
|
|
let nudge = published.recv().await.expect("setup nudge published");
|
|
gate_server.abort();
|
|
query_server.abort();
|
|
(edit, nudge)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn setup_listener_nudges_threaded_edit_at_original_root() {
|
|
let root = "77".repeat(32);
|
|
let original = crate::edit_routing::test_support::message(Some(&root));
|
|
let (edit, nudge) =
|
|
nudge_for_edit(&original.id.to_hex(), serde_json::json!([original])).await;
|
|
let e_tags = nudge_e_tags(&nudge);
|
|
assert_eq!(
|
|
e_tags,
|
|
vec![vec!["e".into(), root, "".into(), "reply".into()]]
|
|
);
|
|
assert_ne!(e_tags[0][1], edit.id.to_hex());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn setup_listener_nudges_top_level_edit_at_original() {
|
|
let original = crate::edit_routing::test_support::message(None);
|
|
let original_id = original.id.to_hex();
|
|
let (edit, nudge) = nudge_for_edit(&original_id, serde_json::json!([original])).await;
|
|
let e_tags = nudge_e_tags(&nudge);
|
|
assert_eq!(
|
|
e_tags,
|
|
vec![vec!["e".into(), original_id, "".into(), "reply".into()]]
|
|
);
|
|
assert_ne!(e_tags[0][1], edit.id.to_hex());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn setup_listener_nudges_unresolved_edit_at_top_level() {
|
|
// The target may itself be a thread reply; claiming it as root would
|
|
// make the relay reject the nudge for mismatched ancestry.
|
|
let target = "88".repeat(32);
|
|
let (_edit, nudge) = nudge_for_edit(&target, serde_json::json!([])).await;
|
|
assert!(nudge_e_tags(&nudge).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn setup_listener_gate_rejects_unmentioned_and_self_edits() {
|
|
let agent_keys = nostr::Keys::generate();
|
|
let agent = agent_keys.public_key().to_hex();
|
|
let target = "99".repeat(32);
|
|
let build = |keys: &nostr::Keys, mention: bool| {
|
|
let mut tags = vec![nostr::Tag::parse(["e", &target]).unwrap()];
|
|
if mention {
|
|
tags.push(nostr::Tag::parse(["p", &agent]).unwrap());
|
|
}
|
|
nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE_EDIT as u16), "edit")
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.unwrap()
|
|
};
|
|
let other = nostr::Keys::generate();
|
|
assert!(setup_listener_admits(&build(&other, true), &agent));
|
|
assert!(!setup_listener_admits(&build(&other, false), &agent));
|
|
assert!(!setup_listener_admits(&build(&agent_keys, true), &agent));
|
|
}
|
|
|
|
fn default_setup_rules() -> Vec<filter::SubscriptionRule> {
|
|
let mut config = crate::build_mcp_servers_tests::test_config();
|
|
config.kinds_override = None;
|
|
build_setup_subscription_rules(&config)
|
|
}
|
|
|
|
/// The setup listener's full admission path (pre-gate plus the production
|
|
/// setup rules) admits an edit that newly mentions the agent and rejects
|
|
/// an edit that does not.
|
|
#[tokio::test]
|
|
async fn setup_rules_admit_only_edits_that_mention_the_agent() {
|
|
let agent = nostr::Keys::generate().public_key().to_hex();
|
|
let rules = default_setup_rules();
|
|
let channel_id = Uuid::new_v4();
|
|
let target = "ab".repeat(32);
|
|
let admitted = |event: nostr::Event| {
|
|
let rules = &rules;
|
|
let agent = agent.clone();
|
|
async move {
|
|
setup_listener_admits(&event, &agent)
|
|
&& filter::match_event(&event, channel_id, rules, &agent)
|
|
.await
|
|
.is_some()
|
|
}
|
|
};
|
|
|
|
let mentioned =
|
|
crate::edit_routing::test_support::edit_event(&target, &[["p", agent.as_str()]]);
|
|
assert!(
|
|
admitted(mentioned).await,
|
|
"a mention added by an edit must reach the setup nudge"
|
|
);
|
|
let unmentioned = crate::edit_routing::test_support::edit_event(&target, &[]);
|
|
assert!(
|
|
!admitted(unmentioned).await,
|
|
"an edit without the agent's p tag must not nudge"
|
|
);
|
|
}
|
|
|
|
#[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-adjacent synchronous guards after an event
|
|
// has passed the structurally mandatory author capability: (a) unmatched
|
|
// filter → no nudge, (b) same event-id → exactly one nudge.
|
|
|
|
fn fake_event_id(byte: u8) -> EventId {
|
|
EventId::from_byte_array([byte; 32])
|
|
}
|
|
|
|
#[test]
|
|
fn test_unmatched_filter_returns_no_nudge() {
|
|
let mut dedup: HashSet<EventId> = HashSet::new();
|
|
let event_id = fake_event_id(0xAA);
|
|
|
|
let result = should_nudge_for_event(event_id, false, &mut dedup);
|
|
|
|
assert!(!result, "unmatched event must not produce a nudge");
|
|
assert!(
|
|
dedup.is_empty(),
|
|
"dedup set must not record an unmatched event"
|
|
);
|
|
}
|
|
|
|
#[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, &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, &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:?}"
|
|
);
|
|
}
|
|
}
|