diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..61ca550cd4 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -50,6 +50,14 @@ The harness discovers channels by querying the relay with the agent's authentica By default, the harness discovers only channels the agent is a **member** of (`GET /api/channels?member=true`). When the agent is added to a new channel, the membership notification subscription auto-subscribes to it. +Channel owners can set **Agent replies** to **Only @mentions** or **Every +message** in channel settings. The relay publishes the authoritative policy in +channel metadata and all connected ACP harnesses replace that channel's +subscription without a restart. This controls delivery for every agent member; +each agent's `RespondTo` author gate still decides who is allowed to trigger it. +New and migrated channels default to mention-only. A harness connected to an +older relay with no policy tag preserves its local mention rule. + **Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Buzz CLI to create new channels (the creator is automatically a member). ## Quick Start (goose) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 19304bf186..40b93bd7f6 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -488,6 +488,20 @@ pub struct ChannelFilter { pub require_mention: bool, } +/// Apply a channel-owned response policy to a resolved agent subscription. +/// +/// When present, the channel-owned policy overrides the agent's local mention +/// rule in either direction. `None` preserves the local rule for compatibility +/// with relays that predate channel-owned response policy metadata. +pub fn apply_agent_response_policy( + filter: &mut ChannelFilter, + policy: Option, +) { + if let Some(policy) = policy { + filter.require_mention = policy == buzz_core::channel::AgentResponsePolicy::Mentions; + } +} + #[derive(Debug)] pub struct Config { pub keys: Keys, @@ -1910,6 +1924,42 @@ mod tests { assert!(!f.require_mention, "most permissive (false) should win"); } + #[test] + fn channel_all_policy_relaxes_mention_filter() { + let mut filter = ChannelFilter { + kinds: Some(vec![1]), + require_mention: true, + }; + apply_agent_response_policy( + &mut filter, + Some(buzz_core::channel::AgentResponsePolicy::All), + ); + assert!(!filter.require_mention); + } + + #[test] + fn channel_mentions_policy_overrides_explicit_local_all_rule() { + let mut filter = ChannelFilter { + kinds: Some(vec![1]), + require_mention: false, + }; + apply_agent_response_policy( + &mut filter, + Some(buzz_core::channel::AgentResponsePolicy::Mentions), + ); + assert!(filter.require_mention); + } + + #[test] + fn missing_channel_policy_preserves_local_rule_for_older_relays() { + let mut filter = ChannelFilter { + kinds: Some(vec![1]), + require_mention: false, + }; + apply_agent_response_policy(&mut filter, None); + assert!(!filter.require_mention); + } + #[test] fn test_rule_applies_all() { let rule = make_rule("test", ChannelScope::All("all".into()), vec![], false); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..ca4e8d8b8d 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1471,7 +1471,12 @@ async fn tokio_main() -> Result<()> { } }; - let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); + let mut channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); + for (channel_id, filter) in &mut channel_filters { + if let Some(channel_info) = channel_info_map.get(channel_id) { + config::apply_agent_response_policy(filter, channel_info.agent_response_policy); + } + } if channel_filters.is_empty() { tracing::warn!("no channel subscriptions resolved — agent will sit idle"); } @@ -1964,10 +1969,20 @@ async fn tokio_main() -> Result<()> { // stripped for a legitimately re-added channel. removed_channels.remove(&ch); - if subscribed_channel_ids.contains(&ch) { - tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed"); - } else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { - tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); + if let Some(mut filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { + if let Some(channel_info) = ctx.channel_info.refresh(ch).await { + config::apply_agent_response_policy( + &mut filter, + channel_info.agent_response_policy, + ); + } + let was_subscribed = subscribed_channel_ids.contains(&ch); + tracing::info!( + channel_id = %ch, + was_subscribed, + require_mention = filter.require_mention, + "membership notification: refreshing channel subscription" + ); if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); } else { @@ -4642,6 +4657,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "dm".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }, ), ( @@ -4649,6 +4665,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "stream".into(), channel_type: "stream".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }, ), ]); @@ -4665,6 +4682,7 @@ mod author_gate_tests { relay::ChannelInfo { name: "unknown".into(), channel_type: "unknown".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }, )]); assert!( diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index b1fd68d044..c9c55f9cc6 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -469,6 +469,7 @@ impl ChannelInfoResolver { PromptChannelInfo { name: info.name, channel_type: info.channel_type, + agent_response_policy: info.agent_response_policy, }, )) }) @@ -495,6 +496,18 @@ impl ChannelInfoResolver { } Some(info) } + + /// Refetch channel metadata and replace the cached value. + /// + /// Membership notifications use this after channel policy changes so a + /// running harness can replace its mention filter without restarting. + pub async fn refresh(&self, channel_id: Uuid) -> Option { + let info = fetch_channel_info(channel_id, &self.rest_client).await?; + if let Ok(mut cache) = self.cache.write() { + cache.insert(channel_id, info.clone()); + } + Some(info) + } } pub struct PromptContext { @@ -2336,17 +2349,28 @@ pub(crate) async fn fetch_channel_info( let ev = events.first()?; let tags = ev.get("tags")?.as_array()?; let mut name = None; + let mut agent_response_policy = None; for tag in tags { if let Some(arr) = tag.as_array() { if arr.first().and_then(|v| v.as_str()) == Some("name") { name = arr.get(1).and_then(|v| v.as_str()); } + if arr.first().and_then(|v| v.as_str()) == Some("agent_response") { + agent_response_policy = match arr.get(1).and_then(|v| v.as_str()) { + Some("mentions") => { + Some(buzz_core::channel::AgentResponsePolicy::Mentions) + } + Some("all") => Some(buzz_core::channel::AgentResponsePolicy::All), + _ => None, + }; + } } } let channel_type = crate::relay::channel_type_from_tags(tags); Some(PromptChannelInfo { name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(), channel_type, + agent_response_policy, }) } Ok(Err(e)) => { diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..6c4e33061f 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -995,6 +995,7 @@ pub struct ContextMessage { pub struct PromptChannelInfo { pub name: String, pub channel_type: String, + pub agent_response_policy: Option, } /// Minimal profile fields needed to label users in ACP prompts. @@ -2976,6 +2977,7 @@ mod tests { let ci = PromptChannelInfo { name: "engineering".into(), channel_type: "stream".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; let prompt = format_prompt( @@ -3007,6 +3009,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; let prompt = format_prompt( @@ -3117,6 +3120,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { @@ -3373,6 +3377,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { @@ -3430,6 +3435,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; // No context fetched — hints only. @@ -3925,6 +3931,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; let prompt = format_prompt( @@ -3988,6 +3995,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions), }; let prompt = format_prompt( diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61e..d24cc0a195 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -127,12 +127,14 @@ use tracing::{debug, info, warn}; use uuid::Uuid; use crate::config::ChannelFilter; +use buzz_core::channel::AgentResponsePolicy; /// Metadata about a channel, populated at discovery time. #[derive(Debug, Clone)] pub struct ChannelInfo { pub name: String, pub channel_type: String, + pub agent_response_policy: Option, } pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { @@ -172,7 +174,7 @@ pub(crate) fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { - let mut meta_map: HashMap = HashMap::new(); + let mut meta_map: HashMap)> = HashMap::new(); let mut archived: std::collections::HashSet = std::collections::HashSet::new(); if let Some(arr) = meta_events.as_array() { for ev in arr { @@ -183,6 +185,7 @@ pub(crate) fn merge_discovered_channels( let mut d_val = None; let mut name = None; let mut is_archived = false; + let mut agent_response_policy = None; for tag in tags { if let Some(arr) = tag.as_array() { match arr.first().and_then(|v| v.as_str()) { @@ -191,6 +194,13 @@ pub(crate) fn merge_discovered_channels( Some("archived") => { is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") } + Some("agent_response") => { + agent_response_policy = match arr.get(1).and_then(|v| v.as_str()) { + Some("mentions") => Some(AgentResponsePolicy::Mentions), + Some("all") => Some(AgentResponsePolicy::All), + _ => None, + }; + } _ => {} } } @@ -203,7 +213,7 @@ pub(crate) fn merge_discovered_channels( } let ch_name = name.unwrap_or("unknown").to_string(); let ch_type = channel_type_from_tags(tags); - meta_map.insert(uuid, (ch_name, ch_type)); + meta_map.insert(uuid, (ch_name, ch_type, agent_response_policy)); } } } @@ -214,10 +224,17 @@ pub(crate) fn merge_discovered_channels( if archived.contains(&uuid) { continue; } - let (name, channel_type) = meta_map + let (name, channel_type, agent_response_policy) = meta_map .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string())); - map.insert(uuid, ChannelInfo { name, channel_type }); + .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string(), None)); + map.insert( + uuid, + ChannelInfo { + name, + channel_type, + agent_response_policy, + }, + ); } map } @@ -4088,6 +4105,7 @@ mod tests { let channel = Uuid::new_v4(); let map = merge_discovered_channels(vec![channel], &serde_json::json!([])); assert_eq!(map[&channel].channel_type, "unknown"); + assert_eq!(map[&channel].agent_response_policy, None); } #[test] @@ -4098,6 +4116,36 @@ mod tests { assert_eq!(map[&channel].channel_type, "dm"); } + #[test] + fn merge_discovered_channels_reads_agent_response_policy() { + let channel = Uuid::new_v4(); + let meta = serde_json::json!([meta_event( + channel, + "agent-room", + &["agent_response", "all"] + )]); + let map = merge_discovered_channels(vec![channel], &meta); + assert_eq!( + map[&channel].agent_response_policy, + Some(AgentResponsePolicy::All) + ); + } + + #[test] + fn merge_discovered_channels_reads_mentions_policy() { + let channel = Uuid::new_v4(); + let meta = serde_json::json!([meta_event( + channel, + "agent-room", + &["agent_response", "mentions"] + )]); + let map = merge_discovered_channels(vec![channel], &meta); + assert_eq!( + map[&channel].agent_response_policy, + Some(AgentResponsePolicy::Mentions) + ); + } + #[test] fn merge_discovered_channels_skips_archived_metadata() { let live = Uuid::new_v4(); diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..1cbc56dcff 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -836,6 +836,7 @@ pub async fn cmd_update_channel( description: Option<&str>, ttl: Option, no_ttl: bool, + agent_response: Option<&str>, ) -> Result<(), CliError> { // Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it, // None (from --no-ttl) clears it, making the channel permanent. @@ -845,15 +846,23 @@ pub async fn cmd_update_channel( (None, false) => None, }; - if name.is_none() && description.is_none() && ttl_change.is_none() { + if name.is_none() && description.is_none() && ttl_change.is_none() && agent_response.is_none() { return Err(CliError::Usage( - "at least one field required (--name, --description, --ttl, --no-ttl)".into(), + "at least one field required (--name, --description, --ttl, --no-ttl, --agent-response)" + .into(), )); } let channel_uuid = parse_uuid(channel_id)?; - let builder = buzz_sdk::build_update_channel(channel_uuid, name, description, None, ttl_change) - .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; + let builder = buzz_sdk::build_update_channel( + channel_uuid, + name, + description, + None, + ttl_change, + agent_response, + ) + .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -1130,6 +1139,7 @@ pub async fn dispatch( description, ttl, no_ttl, + agent_response, } => { cmd_update_channel( client, @@ -1138,6 +1148,9 @@ pub async fn dispatch( description.as_deref(), ttl, no_ttl, + agent_response + .as_ref() + .map(crate::AgentResponsePolicy::as_str), ) .await } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0b46734584..7c1f81362d 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -131,6 +131,32 @@ impl std::fmt::Display for ChannelVisibility { } } +#[derive(Clone, clap::ValueEnum)] +pub enum AgentResponsePolicy { + #[value(name = "mentions")] + Mentions, + #[value(name = "all")] + All, +} + +impl AgentResponsePolicy { + fn as_str(&self) -> &'static str { + match self { + Self::Mentions => "mentions", + Self::All => "all", + } + } +} + +impl std::fmt::Display for AgentResponsePolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Mentions => write!(f, "mentions"), + Self::All => write!(f, "all"), + } + } +} + #[derive(Clone, clap::ValueEnum)] pub enum PresenceStatus { #[value(name = "online")] @@ -570,7 +596,7 @@ pub enum ChannelsCmd { #[arg(long, value_name = "PATH")] templates_file: Option, }, - /// Update channel name, description, or ephemeral TTL + /// Update channel settings Update { /// Channel UUID #[arg(long)] @@ -588,6 +614,10 @@ pub enum ChannelsCmd { /// Clear an existing TTL, making the channel permanent. #[arg(long)] no_ttl: bool, + /// Agent response behavior for the channel. `all` delivers untagged + /// messages to every agent member; `mentions` keeps mention filtering. + #[arg(long, value_enum)] + agent_response: Option, }, /// Set the channel topic Topic { diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 5c20afefd4..35fa71a15e 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -67,6 +67,46 @@ pub enum ChannelType { Workflow, } +/// Whether agents in a channel receive only explicit mentions or every message. +/// +/// This is channel-owned policy. Agent-specific author gates still decide who +/// is allowed to start a turn after a message reaches the harness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentResponsePolicy { + /// Agents receive messages that explicitly mention their public key. + Mentions, + /// Agents receive every subscribed message, including untagged messages. + All, +} + +impl AgentResponsePolicy { + /// Canonical string representation used by the database and Nostr tags. + pub fn as_str(&self) -> &'static str { + match self { + Self::Mentions => "mentions", + Self::All => "all", + } + } +} + +impl fmt::Display for AgentResponsePolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for AgentResponsePolicy { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "mentions" => Ok(Self::Mentions), + "all" => Ok(Self::All), + other => Err(format!("unknown agent response policy: {other:?}")), + } + } +} + impl ChannelType { /// Canonical string representation (matches DB enum and Nostr tags). pub fn as_str(&self) -> &'static str { @@ -180,7 +220,9 @@ impl FromStr for MemberRole { #[cfg(test)] mod tests { - use super::canonical_channel_name; + use std::str::FromStr; + + use super::{canonical_channel_name, AgentResponsePolicy}; #[test] fn channel_names_trim_whitespace_and_drop_all_leading_hashes() { @@ -195,4 +237,18 @@ mod tests { assert_eq!(canonical_channel_name("### ###"), ""); assert_eq!(canonical_channel_name("channel#topic"), "channel#topic"); } + + #[test] + fn agent_response_policy_round_trips_canonical_values() { + assert_eq!( + AgentResponsePolicy::from_str("mentions").unwrap(), + AgentResponsePolicy::Mentions + ); + assert_eq!( + AgentResponsePolicy::from_str("all").unwrap(), + AgentResponsePolicy::All + ); + assert_eq!(AgentResponsePolicy::All.as_str(), "all"); + assert!(AgentResponsePolicy::from_str("sometimes").is_err()); + } } diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..3c904cb682 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -14,7 +14,7 @@ use buzz_core::CommunityId; // Re-export the canonical enum definitions from buzz-core. // These live in core (zero I/O deps) so the SDK can share them // without pulling in sqlx/tokio. -pub use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; +pub use buzz_core::channel::{AgentResponsePolicy, ChannelType, ChannelVisibility, MemberRole}; /// A channel row as returned from the database. #[derive(Debug, Clone)] @@ -63,6 +63,8 @@ pub struct ChannelRecord { pub ttl_seconds: Option, /// Deadline by which a new message must arrive or the channel is auto-archived. pub ttl_deadline: Option>, + /// Whether agents receive mentions only or every message in this channel. + pub agent_response_policy: String, } /// A channel membership row as returned from the database. @@ -153,7 +155,7 @@ pub async fn create_channel( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, agent_response_policy FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -253,7 +255,7 @@ pub async fn create_channel_with_id( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, agent_response_policy FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -281,7 +283,7 @@ pub async fn get_channel( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, agent_response_policy FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) @@ -788,7 +790,7 @@ pub async fn list_channels( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, agent_response_policy FROM channels WHERE community_id = $1 AND deleted_at IS NULL AND visibility::text = $2 ORDER BY created_at DESC @@ -808,7 +810,7 @@ pub async fn list_channels( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, agent_response_policy FROM channels WHERE community_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC @@ -856,7 +858,7 @@ async fn get_channel_tx( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, agent_response_policy FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) @@ -958,7 +960,7 @@ pub async fn get_accessible_channels( c.nip29_group_id, c.topic_required, c.max_members, c.topic, c.topic_set_by, c.topic_set_at, c.purpose, c.purpose_set_by, c.purpose_set_at, - c.ttl_seconds, c.ttl_deadline, + c.ttl_seconds, c.ttl_deadline, c.agent_response_policy, (cm.channel_id IS NOT NULL) AS is_member FROM channels c LEFT JOIN channel_members cm @@ -1095,6 +1097,9 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { let purpose_set_at: Option> = row.try_get("purpose_set_at").unwrap_or(None); let ttl_seconds: Option = row.try_get("ttl_seconds").unwrap_or(None); let ttl_deadline: Option> = row.try_get("ttl_deadline").unwrap_or(None); + let agent_response_policy: String = row + .try_get("agent_response_policy") + .unwrap_or_else(|_| AgentResponsePolicy::Mentions.as_str().to_string()); Ok(ChannelRecord { id, @@ -1119,6 +1124,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose_set_at, ttl_seconds, ttl_deadline, + agent_response_policy, }) } @@ -1149,6 +1155,8 @@ pub struct ChannelUpdate { /// ephemeral TTL (channel becomes permanent), `Some(Some(secs))` sets it. /// On any change the `ttl_deadline` is reset to `NOW() + ttl_seconds`. pub ttl_seconds: Option>, + /// New agent response policy (`"mentions"`/`"all"`), or `None` to leave unchanged. + pub agent_response_policy: Option, } /// Updates channel metadata dynamically. @@ -1165,6 +1173,7 @@ pub async fn update_channel( && updates.description.is_none() && updates.visibility.is_none() && updates.ttl_seconds.is_none() + && updates.agent_response_policy.is_none() { return Err(DbError::InvalidData( "at least one field must be provided for update".to_string(), @@ -1177,6 +1186,11 @@ pub async fn update_channel( return Err(DbError::InvalidData("channel name is required".into())); } } + if let Some(policy) = updates.agent_response_policy.as_deref() { + policy + .parse::() + .map_err(DbError::InvalidData)?; + } // Build SET clause dynamically — only include fields that are provided. // Track parameter index for positional placeholders. @@ -1206,6 +1220,10 @@ pub async fn update_channel( None => set_parts.push("ttl_deadline = NULL".to_string()), } } + if updates.agent_response_policy.is_some() { + set_parts.push(format!("agent_response_policy = ${param_idx}")); + param_idx += 1; + } let channel_param_idx = param_idx + 1; let sql = format!( "UPDATE channels SET {}, updated_at = NOW() WHERE community_id = ${param_idx} AND id = ${channel_param_idx} AND deleted_at IS NULL", @@ -1225,6 +1243,9 @@ pub async fn update_channel( if let Some(ref ttl) = updates.ttl_seconds { q = q.bind(*ttl); } + if let Some(ref policy) = updates.agent_response_policy { + q = q.bind(policy); + } q = q.bind(community_id.as_uuid()); q = q.bind(channel_id); diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/dm.rs index 89e15c7026..5d37e49aa5 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/dm.rs @@ -511,6 +511,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None), ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None), ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None), + agent_response_policy: "mentions".to_string(), }) } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1d1b7e05d4..3eaf4d9d95 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 25); + assert_eq!(migrations.len(), 26); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -899,11 +899,18 @@ mod tests { .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); assert!(!relay_invites.contains("_operator_global_tables")); + assert_eq!(migrations[25].version, 26); + let agent_response = migrations[25].sql.as_str(); + assert!(agent_response.contains("ADD COLUMN agent_response_policy")); + assert!(agent_response.contains("DEFAULT 'mentions'")); + assert!(agent_response.contains("IN ('mentions', 'all')")); + let desired_schema = include_str!("../../../schema/schema.sql"); assert!( desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", ); + assert!(desired_schema.contains("agent_response_policy")); } #[test] diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 65d04ef0ba..00ec7c9200 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -500,6 +500,7 @@ pub async fn validate_admin_event( "purpose", "visibility", "ttl", + "agent_response", ]; let has_recognized = event .tags @@ -507,7 +508,7 @@ pub async fn validate_admin_event( .any(|t| RECOGNIZED_TAGS.contains(&t.kind().to_string().as_str())); if !has_recognized { return Err(anyhow::anyhow!( - "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl)" + "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, agent_response)" )); } @@ -586,11 +587,33 @@ pub async fn validate_admin_event( } } - // name/about/archived/visibility/ttl require owner/admin; + // Validate the channel-owned agent response policy before storage. + for t in event.tags.iter() { + if t.kind().to_string() == "agent_response" { + match t.content() { + Some("mentions") | Some("all") => {} + Some(v) => { + return Err(anyhow::anyhow!( + "invalid agent_response value: {v} (must be \"mentions\" or \"all\")" + )); + } + None => { + return Err(anyhow::anyhow!("agent_response tag must have a value")); + } + } + } + } + + // name/about/archived/visibility/ttl/agent_response require owner/admin; // topic/purpose allow any member. let has_privileged_tag = event.tags.iter().any(|t| { let k = t.kind().to_string(); - k == "name" || k == "about" || k == "archived" || k == "visibility" || k == "ttl" + k == "name" + || k == "about" + || k == "archived" + || k == "visibility" + || k == "ttl" + || k == "agent_response" }); if has_privileged_tag { let members = state.db.get_members(tenant.community(), channel_id).await?; @@ -612,7 +635,7 @@ pub async fn validate_admin_event( return Ok(()); } Err(anyhow::anyhow!( - "actor not authorized for name/about/archived/visibility/ttl changes" + "actor not authorized for name/about/archived/visibility/ttl/agent_response changes" )) } } @@ -1105,6 +1128,10 @@ pub async fn emit_group_discovery_events( if let Some(ref deadline) = channel.ttl_deadline { tags.push(Tag::parse(["ttl_deadline", &deadline.to_rfc3339()])?); } + tags.push(Tag::parse([ + "agent_response", + &channel.agent_response_policy, + ])?); emit_addressable_discovery_event( tenant, state, @@ -1437,6 +1464,7 @@ async fn handle_edit_metadata( extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; let actor_bytes = event.pubkey.to_bytes().to_vec(); let actor_hex = hex::encode(&actor_bytes); + let mut agent_response_changed = false; for tag in event.tags.iter() { let key = tag.kind().to_string(); @@ -1570,6 +1598,29 @@ async fn handle_edit_metadata( ) .await?; } + "agent_response" => { + state + .db + .update_channel( + tenant.community(), + channel_id, + buzz_db::channel::ChannelUpdate { + agent_response_policy: Some(val.to_string()), + ..Default::default() + }, + ) + .await?; + agent_response_changed = true; + emit_system_message( + tenant, + state, + channel_id, + serde_json::json!({ + "type": "agent_response_changed", "actor": actor_hex, "policy": val + }), + ) + .await?; + } "archived" => { match val { "true" => { @@ -1650,6 +1701,30 @@ async fn handle_edit_metadata( warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); } + if agent_response_changed { + // ACP harnesses keep a global membership subscription alive. Reusing + // this notification gives every connected agent a durable signal to + // refetch metadata and replace its channel subscription immediately. + for member in state.db.get_members(tenant.community(), channel_id).await? { + if let Err(e) = emit_membership_notification( + tenant, + state, + channel_id, + &member.pubkey, + &actor_bytes, + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!( + channel = %channel_id, + error = %e, + "agent-response resubscribe notification failed" + ); + } + } + } + Ok(()) } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a..c48cbbf116 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -597,7 +597,7 @@ pub fn build_leave(channel_id: Uuid) -> Result { Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags)) } -/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl (kind 9002). +/// Build a NIP-29 edit-metadata event for channel settings (kind 9002). /// /// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the /// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). @@ -607,10 +607,17 @@ pub fn build_update_channel( about: Option<&str>, visibility: Option<&str>, ttl: Option>, + agent_response: Option<&str>, ) -> Result { - if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() { + if name.is_none() + && about.is_none() + && visibility.is_none() + && ttl.is_none() + && agent_response.is_none() + { return Err(SdkError::InvalidTag( - "at least one of name, about, visibility, or ttl must be provided".into(), + "at least one of name, about, visibility, ttl, or agent_response must be provided" + .into(), )); } if let Some(v) = visibility { @@ -620,6 +627,13 @@ pub fn build_update_channel( )); } } + if let Some(policy) = agent_response { + if policy != "mentions" && policy != "all" { + return Err(SdkError::InvalidTag( + "agent_response must be \"mentions\" or \"all\"".into(), + )); + } + } if name .map(buzz_core::channel::canonical_channel_name) .is_some_and(|name| name.trim().is_empty()) @@ -645,6 +659,9 @@ pub fn build_update_channel( None => tags.push(tag(&["ttl", ""])?), } } + if let Some(policy) = agent_response { + tags.push(tag(&["agent_response", policy])?); + } Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags)) } @@ -2410,7 +2427,8 @@ mod tests { fn update_channel_name_and_about() { let cid = uuid(); let ev = sign( - build_update_channel(cid, Some("new-name"), Some("new about"), None, None).unwrap(), + build_update_channel(cid, Some("new-name"), Some("new about"), None, None, None) + .unwrap(), ); assert_eq!(ev.kind.as_u16(), 9002); assert!(has_tag(&ev, "name", "new-name")); @@ -2419,15 +2437,16 @@ mod tests { #[test] fn update_channel_strips_all_leading_hashes_from_name() { - let ev = - sign(build_update_channel(uuid(), Some(" ###new-name "), None, None, None).unwrap()); + let ev = sign( + build_update_channel(uuid(), Some(" ###new-name "), None, None, None, None).unwrap(), + ); assert!(has_tag(&ev, "name", "new-name")); } #[test] fn update_channel_rejects_hash_only_name() { assert!(matches!( - build_update_channel(uuid(), Some(" ### "), None, None, None), + build_update_channel(uuid(), Some(" ### "), None, None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -2435,8 +2454,9 @@ mod tests { #[test] fn update_channel_visibility_and_ttl() { let cid = uuid(); - let ev = - sign(build_update_channel(cid, None, None, Some("private"), Some(Some(3600))).unwrap()); + let ev = sign( + build_update_channel(cid, None, None, Some("private"), Some(Some(3600)), None).unwrap(), + ); assert_eq!(ev.kind.as_u16(), 9002); assert!(has_tag(&ev, "visibility", "private")); assert!(has_tag(&ev, "ttl", "3600")); @@ -2445,7 +2465,7 @@ mod tests { #[test] fn update_channel_clears_ttl() { let cid = uuid(); - let ev = sign(build_update_channel(cid, None, None, None, Some(None)).unwrap()); + let ev = sign(build_update_channel(cid, None, None, None, Some(None), None).unwrap()); assert!(has_tag(&ev, "ttl", "")); } @@ -2453,7 +2473,18 @@ mod tests { fn update_channel_invalid_visibility_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, Some("secret"), None), + build_update_channel(cid, None, None, Some("secret"), None, None), + Err(SdkError::InvalidTag(_)) + )); + } + + #[test] + fn update_channel_agent_response_policy() { + let cid = uuid(); + let ev = sign(build_update_channel(cid, None, None, None, None, Some("all")).unwrap()); + assert!(has_tag(&ev, "agent_response", "all")); + assert!(matches!( + build_update_channel(cid, None, None, None, None, Some("sometimes")), Err(SdkError::InvalidTag(_)) )); } @@ -2462,7 +2493,7 @@ mod tests { fn update_channel_no_fields_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, None, None), + build_update_channel(cid, None, None, None, None, None), Err(SdkError::InvalidTag(_)) )); } diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c4807..893eeab3b0 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -695,6 +695,9 @@ pub struct UpdateChannelInput { /// Absent = leave unchanged, `null` = clear (permanent), seconds = set. #[serde(default, deserialize_with = "crate::util::double_option")] pub ttl_seconds: Option>, + /// Absent leaves the setting unchanged; valid values are `mentions` and `all`. + #[serde(default)] + pub agent_response: Option, } #[tauri::command] @@ -709,6 +712,7 @@ pub async fn update_channel( input.description.as_deref(), input.visibility.as_deref(), input.ttl_seconds, + input.agent_response.as_deref(), )?; submit_event(builder, &state).await?; diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 5b65695a91..40e0138ff2 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -286,6 +286,7 @@ fn starter_match_requires_open_unarchived_stream_by_normalized_name() { is_member: true, ttl_seconds: None, ttl_deadline: None, + agent_response_policy: "mentions".to_string(), }; assert!(is_matching_starter_channel(&channel, spec)); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..066e3a745b 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -13,7 +13,7 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; -// ── Constants ──────────────────────────────────────────────────────────────── +mod channel_policy; /// Maximum content size — matches buzz-sdk (64 KiB). const MAX_CONTENT_BYTES: usize = 64 * 1024; @@ -179,19 +179,17 @@ pub fn build_leave(channel_id: Uuid) -> Result { Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags)) } -/// Kind 9002 — update channel name/description/visibility/ttl. -/// -/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the -/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). +/// Kind 9002 — update channel settings. pub fn build_update_channel( channel_id: Uuid, name: Option<&str>, about: Option<&str>, visibility: Option<&str>, ttl: Option>, + agent_response: Option<&str>, ) -> Result { - if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() { - return Err("at least one of name, about, visibility, or ttl must be provided".into()); + if (name, about, visibility, ttl, agent_response) == (None, None, None, None, None) { + return Err("provide at least one channel setting".into()); } if let Some(v) = visibility { if v != "open" && v != "private" { @@ -218,6 +216,7 @@ pub fn build_update_channel( None => tags.push(tag(vec!["ttl", ""])?), } } + channel_policy::push_agent_response_tag(&mut tags, agent_response)?; Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags)) } @@ -854,7 +853,7 @@ mod tests { fn channel_builders_reject_hash_only_names() { let channel_id = Uuid::new_v4(); assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err()); - assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err()); + assert!(build_update_channel(channel_id, Some("###"), None, None, None, None).is_err()); } /// Builder layout regression for the NIP-IA owner-of-agent archive flow. /// Compares against `docs/nips/NIP-IA.md` §Vector 1. diff --git a/desktop/src-tauri/src/events/channel_policy.rs b/desktop/src-tauri/src/events/channel_policy.rs new file mode 100644 index 0000000000..d43f8a1621 --- /dev/null +++ b/desktop/src-tauri/src/events/channel_policy.rs @@ -0,0 +1,15 @@ +use nostr::Tag; + +pub(super) fn push_agent_response_tag( + tags: &mut Vec, + policy: Option<&str>, +) -> Result<(), String> { + let Some(policy) = policy else { + return Ok(()); + }; + if !matches!(policy, "mentions" | "all") { + return Err("agent_response must be \"mentions\" or \"all\"".into()); + } + tags.push(Tag::parse(vec!["agent_response", policy]).map_err(|e| format!("invalid tag: {e}"))?); + Ok(()) +} diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..e9f05fac85 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -129,6 +129,8 @@ pub struct ChannelInfo { pub is_member: bool, pub ttl_seconds: Option, pub ttl_deadline: Option, + #[serde(default = "default_agent_response_policy")] + pub agent_response_policy: String, } #[derive(Serialize, Deserialize)] @@ -155,6 +157,12 @@ pub struct ChannelDetailInfo { pub nip29_group_id: Option, pub ttl_seconds: Option, pub ttl_deadline: Option, + #[serde(default = "default_agent_response_policy")] + pub agent_response_policy: String, +} + +fn default_agent_response_policy() -> String { + "mentions".to_string() } #[derive(Serialize, Deserialize)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..2214807d1d 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -1,11 +1,6 @@ //! Nostr event → desktop model converters. //! -//! These pure functions translate raw Nostr protocol events into the -//! model types expected by the Tauri frontend commands. -//! -//! All converters here are I/O-free and deterministic — they take owned -//! or borrowed events and return models. This makes them trivially -//! testable with hand-crafted events (see the `tests` module below). +//! Pure, deterministic conversions keep protocol parsing testable without I/O. use std::collections::{BTreeSet, HashMap}; @@ -14,6 +9,7 @@ use serde_json::{json, Value}; use crate::models::*; +mod channel_policy; mod user_search; pub use user_search::{ list_user_search_results, rank_user_search_results, search_users_from_events, @@ -157,6 +153,7 @@ pub fn channel_info_from_event( // Ephemeral channel TTL — relay emits ["ttl", ""] and ["ttl_deadline", ""]. let ttl_seconds = first_tag_value(event, "ttl").and_then(|v| v.parse::().ok()); let ttl_deadline = first_tag_value(event, "ttl_deadline").map(str::to_string); + let agent_response_policy = channel_policy::from_event(event); Ok(ChannelInfo { id, @@ -175,6 +172,7 @@ pub fn channel_info_from_event( is_member: is_member.unwrap_or(true), ttl_seconds, ttl_deadline, + agent_response_policy, }) } @@ -237,6 +235,7 @@ pub fn channel_detail_from_event(event: &Event) -> Result().ok()), ttl_deadline: first_tag_value(event, "ttl_deadline").map(str::to_string), + agent_response_policy: channel_policy::from_event(event), }) } diff --git a/desktop/src-tauri/src/nostr_convert/channel_policy.rs b/desktop/src-tauri/src/nostr_convert/channel_policy.rs new file mode 100644 index 0000000000..ab6c4eb6f6 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/channel_policy.rs @@ -0,0 +1,41 @@ +use nostr::Event; + +pub(super) fn from_event(event: &Event) -> String { + event + .tags + .iter() + .find_map(|tag| match tag.as_slice() { + [name, value, ..] + if name == "agent_response" && matches!(value.as_str(), "mentions" | "all") => + { + Some(value.clone()) + } + _ => None, + }) + .unwrap_or_else(|| "mentions".to_string()) +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + use super::from_event; + + fn event(policy: Option<&str>) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", "channel-id"]).unwrap()]; + if let Some(policy) = policy { + tags.push(Tag::parse(["agent_response", policy]).unwrap()); + } + EventBuilder::new(Kind::Custom(39000), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn reads_valid_policy_and_defaults_invalid_or_missing_values() { + assert_eq!(from_event(&event(Some("all"))), "all"); + assert_eq!(from_event(&event(Some("sometimes"))), "mentions"); + assert_eq!(from_event(&event(None)), "mentions"); + } +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 35ad4a63af..3f0b6b70d2 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -114,6 +114,13 @@ with a TypeScript lookup table or an id comparison in a component. published or removed. A queued update must stay visibly queued, and the catalog itself must render only relay-confirmed publications — never an optimistic local persona. +11. **Mention delivery can be channel-owned.** A channel's NIP-29 metadata may + carry `agent_response=mentions|all`, which authoritatively sets the ACP + mention filter for every agent member. This policy is edited with channel + settings and propagated by the relay; do not model it as a managed-agent + env override. Missing metadata preserves legacy local behavior for older + relays. Per-agent `RespondTo` author gates remain authoritative after + delivery and must never be weakened by a channel setting. ## The tests that enforce this diff --git a/desktop/src/features/channels/lib/agentResponsePolicy.ts b/desktop/src/features/channels/lib/agentResponsePolicy.ts new file mode 100644 index 0000000000..206dcbdf17 --- /dev/null +++ b/desktop/src/features/channels/lib/agentResponsePolicy.ts @@ -0,0 +1,9 @@ +import type { AgentResponsePolicy } from "@/shared/api/types"; + +export function agentResponseEmoji(policy: AgentResponsePolicy): string { + return policy === "all" ? "💬" : "🏷️"; +} + +export function agentResponseLabel(policy: AgentResponsePolicy): string { + return policy === "all" ? "Every message" : "Only @mentions"; +} diff --git a/desktop/src/features/channels/ui/ChannelAgentResponseSettings.tsx b/desktop/src/features/channels/ui/ChannelAgentResponseSettings.tsx new file mode 100644 index 0000000000..65ddf5dffc --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelAgentResponseSettings.tsx @@ -0,0 +1,88 @@ +import { AtSign, ChevronDown, MessagesSquare } from "lucide-react"; + +import { + agentResponseEmoji, + agentResponseLabel, +} from "@/features/channels/lib/agentResponsePolicy"; +import type { AgentResponsePolicy } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { cn } from "@/shared/lib/cn"; + +export function ChannelAgentResponseSettings({ + disabled, + onPolicyChange, + policy, + testIdPrefix, +}: { + disabled?: boolean; + onPolicyChange: (policy: AgentResponsePolicy) => void; + policy: AgentResponsePolicy; + testIdPrefix: string; +}) { + const policyLabel = `${agentResponseEmoji(policy)} ${agentResponseLabel(policy)}`; + + return ( +
+
+
Agent replies
+
+ Applies to every agent in this channel +
+
+ + + + + event.preventDefault()} + style={{ minWidth: "var(--radix-dropdown-menu-trigger-width)" }} + > + + onPolicyChange(value === "all" ? "all" : "mentions") + } + value={policy} + > + + + 🏷️ Only @mentions + + + 💬 Every message + + + + +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelAgentResponseSummaryRow.tsx b/desktop/src/features/channels/ui/ChannelAgentResponseSummaryRow.tsx new file mode 100644 index 0000000000..5476a6976a --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelAgentResponseSummaryRow.tsx @@ -0,0 +1,102 @@ +import { + AtSign, + ChevronDown, + LoaderCircle, + MessagesSquare, +} from "lucide-react"; + +import { + agentResponseEmoji, + agentResponseLabel, +} from "@/features/channels/lib/agentResponsePolicy"; +import type { AgentResponsePolicy } from "@/shared/api/types"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export function ChannelAgentResponseSummaryRow({ + canManage, + isPending, + onPolicyChange, + policy, +}: { + canManage: boolean; + isPending: boolean; + onPolicyChange: (policy: AgentResponsePolicy) => void; + policy: AgentResponsePolicy; +}) { + const status = `${agentResponseEmoji(policy)} ${agentResponseLabel(policy)}`; + const content = ( + <> + + + + + + Agent replies + + + {status} + + + + ); + + if (!canManage) { + return ( +
+ {content} +
+ ); + } + + return ( + + + + + + + onPolicyChange(value === "all" ? "all" : "mentions") + } + value={policy} + > + + + 🏷️ Only @mentions + + + 💬 Every message + + + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index 6d56e4dbfc..e6dbd83345 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -9,6 +9,10 @@ import { } from "lucide-react"; import type { Channel } from "@/shared/api/types"; +import { + agentResponseEmoji, + agentResponseLabel, +} from "@/features/channels/lib/agentResponsePolicy"; import { canonicalChannelName, channelNamesMatch, @@ -794,6 +798,13 @@ function ChannelCard({ #

+ + {agentResponseEmoji(channel.agentResponsePolicy)} + {channel.name}

{channel.archivedAt ? ( diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 3c1727e00a..6b4ff1faf3 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -35,7 +35,7 @@ import { DEFAULT_EPHEMERAL_TTL_SECONDS, formatTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; -import type { Channel } from "@/shared/api/types"; +import type { AgentResponsePolicy, Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { Button } from "@/shared/ui/button"; @@ -70,6 +70,8 @@ import { } from "./channelFormStyles"; import { ChannelTypeSettings } from "./ChannelTypeSettings"; import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings"; +import { ChannelAgentResponseSettings } from "./ChannelAgentResponseSettings"; +import { ChannelAgentResponseSummaryRow } from "./ChannelAgentResponseSummaryRow"; import { ChannelHero, ChannelQuickAction, @@ -98,6 +100,20 @@ type ChannelManagementSheetProps = { transparentChrome?: boolean; }; +function agentResponseUpdateErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("must include at least one metadata tag") || + (message.includes("agent_response") && message.includes("recognized")) + ) { + return "This relay does not support agent reply settings yet"; + } + if (message.includes("not authorized")) { + return "Only channel owners and admins can change agent replies"; + } + return `Could not update agent replies: ${message}`; +} + export function ChannelManagementSheet({ animateSplitEnter = false, channel, @@ -165,6 +181,8 @@ export function ChannelManagementSheet({ const [ttlSecondsDraft, setTtlSecondsDraft] = React.useState( DEFAULT_EPHEMERAL_TTL_SECONDS, ); + const [agentResponseDraft, setAgentResponseDraft] = + React.useState("mentions"); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false); const [isConvertingVisibility, setIsConvertingVisibility] = @@ -202,6 +220,7 @@ export function ChannelManagementSheet({ setIsPrivateDraft(detail.visibility === "private"); setIsEphemeralDraft(detail.ttlSeconds !== null); setTtlSecondsDraft(detail.ttlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS); + setAgentResponseDraft(detail.agentResponsePolicy); setHasUserEditedChannelDraft(false); setActiveView("summary"); }, [detail, open]); @@ -236,6 +255,8 @@ export function ChannelManagementSheet({ const currentVisibility = detail?.visibility ?? channel.visibility; const currentTtlSeconds = detail?.ttlSeconds ?? null; + const currentAgentResponse = + detail?.agentResponsePolicy ?? channel.agentResponsePolicy; const nextVisibility: "open" | "private" = isPrivateDraft ? "private" : "open"; @@ -251,7 +272,9 @@ export function ChannelManagementSheet({ const descriptionDirty = descriptionDraft.trim() !== resolvedChannel.description.trim(); const isSavingChannelEdits = updateChannelDetailsMutation.isPending; - const hasChannelEditChanges = nameDirty || descriptionDirty || lifecycleDirty; + const agentResponseDirty = agentResponseDraft !== currentAgentResponse; + const hasChannelEditChanges = + nameDirty || descriptionDirty || lifecycleDirty || agentResponseDirty; const canSaveChannelEdits = nameDraft.trim().length > 0 && hasUserEditedChannelDraft && @@ -270,6 +293,7 @@ export function ChannelManagementSheet({ setDescriptionDraft(resolvedChannel.description); setIsEphemeralDraft(currentTtlSeconds !== null); setTtlSecondsDraft(currentTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS); + setAgentResponseDraft(currentAgentResponse); setHasUserEditedChannelDraft(false); } @@ -278,7 +302,7 @@ export function ChannelManagementSheet({ async function handleSaveChannelEdits() { try { - if (nameDirty || descriptionDirty || lifecycleDirty) { + if (hasChannelEditChanges) { await updateChannelDetailsMutation.mutateAsync({ description: descriptionDirty ? descriptionDraft.trim() : undefined, name: nameDirty ? nameDraft.trim() : undefined, @@ -288,6 +312,7 @@ export function ChannelManagementSheet({ lifecycleDirty && nextVisibility !== currentVisibility ? nextVisibility : undefined, + agentResponse: agentResponseDirty ? agentResponseDraft : undefined, }); } @@ -317,6 +342,21 @@ export function ChannelManagementSheet({ } } + async function handleAgentResponseChange(policy: AgentResponsePolicy) { + if (policy === currentAgentResponse) { + return; + } + try { + await updateChannelDetailsMutation.mutateAsync({ agentResponse: policy }); + setAgentResponseDraft(policy); + toast.success( + `Agent replies: ${policy === "all" ? "every message" : "mentions only"}`, + ); + } catch (error) { + toast.error(agentResponseUpdateErrorMessage(error)); + } + } + return ( + { + setAgentResponseDraft(policy); + setHasUserEditedChannelDraft(true); + }} + policy={agentResponseDraft} + testIdPrefix="channel-management" + /> ) : null} @@ -583,6 +636,7 @@ type ChannelManagementPanelContentProps = { canLeave: boolean; canManageChannel: boolean; canOpenCanvas: boolean; + isAgentResponsePending: boolean; canvasPreview?: string; canvasQuery: { isLoading: boolean }; channelId: string | null; @@ -601,6 +655,7 @@ type ChannelManagementPanelContentProps = { memberCount: number; membersError: unknown; onOpenChange: (open: boolean) => void; + onAgentResponseChange: (policy: AgentResponsePolicy) => Promise; resolvedChannel: Channel; setActiveView: React.Dispatch>; setIsEditDialogOpen: React.Dispatch>; @@ -615,6 +670,7 @@ function ChannelManagementPanelContent({ canLeave, canManageChannel, canOpenCanvas, + isAgentResponsePending, canvasPreview, canvasQuery, channelId, @@ -633,6 +689,7 @@ function ChannelManagementPanelContent({ memberCount, membersError, onOpenChange, + onAgentResponseChange, resolvedChannel, setActiveView, setIsEditDialogOpen, @@ -841,6 +898,16 @@ function ChannelManagementPanelContent({ testId="channel-management-member-count" value={`${memberCount}`} /> + {resolvedChannel.channelType !== "dm" ? ( + { + void onAgentResponseChange(policy); + }} + policy={resolvedChannel.agentResponsePolicy} + /> + ) : null} {isArchived ? ( onSelectChannel(channel.id)} - tooltip={resolvedLabel} + tooltip={tooltip} type="button" > + {responseEmoji ? ( + + ) : null} {resolvedLabel} {ephemeralDisplay ? ( diff --git a/desktop/src/shared/api/tauriChannels.ts b/desktop/src/shared/api/tauriChannels.ts index 80520b3350..d93ffe6034 100644 --- a/desktop/src/shared/api/tauriChannels.ts +++ b/desktop/src/shared/api/tauriChannels.ts @@ -30,6 +30,7 @@ export type RawChannel = { is_member?: boolean; ttl_seconds: number | null; ttl_deadline: string | null; + agent_response_policy?: "mentions" | "all"; }; type RawChannelDetail = RawChannel & { @@ -76,6 +77,7 @@ export function fromRawChannel(channel: RawChannel): Channel { isMember: channel.is_member ?? true, ttlSeconds: channel.ttl_seconds, ttlDeadline: channel.ttl_deadline, + agentResponsePolicy: channel.agent_response_policy ?? "mentions", }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..8d32203521 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -1,7 +1,7 @@ export type ChannelType = "stream" | "forum" | "dm"; export type ChannelVisibility = "open" | "private"; +export type AgentResponsePolicy = "mentions" | "all"; export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot"; - export type Channel = { id: string; name: string; @@ -19,8 +19,8 @@ export type Channel = { isMember: boolean; ttlSeconds: number | null; ttlDeadline: string | null; + agentResponsePolicy: AgentResponsePolicy; }; - export type ChannelDetail = Channel & { createdBy: string; createdAt: string; @@ -33,7 +33,6 @@ export type ChannelDetail = Channel & { maxMembers: number | null; nip29GroupId: string | null; }; - export type ChannelMember = { pubkey: string; role: ChannelRole; @@ -61,6 +60,7 @@ export type UpdateChannelInput = { visibility?: ChannelVisibility; /** Omit to leave unchanged, `null` to clear (permanent), or a positive number of seconds to set. */ ttlSeconds?: number | null; + agentResponse?: AgentResponsePolicy; }; export type SetChannelTopicInput = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..22514b7c7a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -555,6 +555,7 @@ type RawChannel = { participant_pubkeys: string[]; ttl_seconds: number | null; ttl_deadline: string | null; + agent_response_policy: "mentions" | "all"; }; type RawChannelWithMembership = RawChannel & { @@ -1345,6 +1346,7 @@ function toRawChannel( participant_pubkeys: [...channel.participant_pubkeys], ttl_seconds: channel.ttl_seconds ?? null, ttl_deadline: channel.ttl_deadline ?? null, + agent_response_policy: channel.agent_response_policy ?? "mentions", is_member: channel.members.some( (member) => member.pubkey.toLowerCase() === currentPubkey, ), @@ -1395,6 +1397,7 @@ function createMockChannel( | "participants" | "ttl_seconds" | "ttl_deadline" + | "agent_response_policy" > & { created_minutes_ago: number; members: RawChannelMember[]; @@ -1402,6 +1405,7 @@ function createMockChannel( participants?: string[]; ttl_seconds?: number | null; ttl_deadline?: string | null; + agent_response_policy?: "mentions" | "all"; updated_minutes_ago?: number; }, ): MockChannel { @@ -1414,6 +1418,7 @@ function createMockChannel( participants: [...(seed.participants ?? [])], ttl_seconds: seed.ttl_seconds ?? null, ttl_deadline: seed.ttl_deadline ?? null, + agent_response_policy: seed.agent_response_policy ?? "mentions", updated_at: isoMinutesAgo( seed.updated_minutes_ago ?? seed.created_minutes_ago, ), @@ -5368,6 +5373,8 @@ async function handleGetChannels(config: E2eConfig | undefined) { participant_pubkeys: pTags, ttl_seconds: getTag("ttl") ? Number(getTag("ttl")) : null, ttl_deadline: getTag("ttl_deadline") ?? null, + agent_response_policy: + getTag("agent_response") === "all" ? "all" : "mentions", is_member: memberSet.has(channelId), }; }) @@ -5909,6 +5916,7 @@ async function handleCreateChannel( archived_at: null, ttl_seconds: args.ttlSeconds ?? null, ttl_deadline: ttlDeadline, + agent_response_policy: "mentions", created_at: ev.created_at ? new Date(ev.created_at * 1000).toISOString() : new Date().toISOString(), @@ -6007,6 +6015,7 @@ async function handleOpenDm( archived_at: null, ttl_seconds: null, ttl_deadline: null, + agent_response_policy: "mentions", created_at: ev?.created_at ? new Date(ev.created_at * 1000).toISOString() : new Date().toISOString(), @@ -6078,6 +6087,8 @@ async function handleGetChannelDetails( : null, ttl_seconds: getTag("ttl") ? Number(getTag("ttl")) : null, ttl_deadline: getTag("ttl_deadline") ?? null, + agent_response_policy: + getTag("agent_response") === "all" ? "all" : "mentions", created_at: ev?.created_at ? new Date(ev.created_at * 1000).toISOString() : new Date().toISOString(), @@ -6131,6 +6142,7 @@ async function handleUpdateChannel( description?: string; visibility?: "open" | "private"; ttlSeconds?: number | null; + agentResponse?: "mentions" | "all"; }, config: E2eConfig | undefined, ) { @@ -6158,6 +6170,9 @@ async function handleUpdateChannel( ? null : new Date(Date.now() + args.ttlSeconds * 1000).toISOString(); } + if (args.agentResponse !== undefined) { + channel.agent_response_policy = args.agentResponse; + } touchMockChannel(channel); return toRawChannelDetail(channel, config); } @@ -6175,6 +6190,9 @@ async function handleUpdateChannel( if (args.ttlSeconds !== undefined) { tags.push(["ttl", args.ttlSeconds === null ? "" : String(args.ttlSeconds)]); } + if (args.agentResponse !== undefined) { + tags.push(["agent_response", args.agentResponse]); + } await submitSignedEvent(config, { kind: 9002, content: "", tags }); // Re-fetch updated metadata @@ -6203,6 +6221,8 @@ async function handleUpdateChannel( ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString(), + agent_response_policy: + getTag("agent_response") === "all" ? "all" : "mentions", created_at: ev?.created_at ? new Date(ev.created_at * 1000).toISOString() : new Date().toISOString(), diff --git a/desktop/tests/e2e/channel-controls.spec.ts b/desktop/tests/e2e/channel-controls.spec.ts index ff88ecbf62..1b61b7ce81 100644 --- a/desktop/tests/e2e/channel-controls.spec.ts +++ b/desktop/tests/e2e/channel-controls.spec.ts @@ -345,4 +345,32 @@ test.describe("channel controls", () => { page.getByRole("dialog", { name: "Edit public channel" }), ).toBeVisible(); }); + + test("11 — agent reply policy persists and updates the channel emoji", async ({ + page, + }) => { + await installMockBridge(page); + await openManagementSheet(page); + + const summaryPolicy = page.getByTestId( + "channel-management-agent-response-summary", + ); + await expect(summaryPolicy).toHaveAccessibleName( + "Agent replies: 🏷️ Only @mentions", + ); + await summaryPolicy.click(); + await page + .getByTestId("channel-management-agent-response-summary-option-all") + .click(); + await expect(summaryPolicy).toHaveAccessibleName( + "Agent replies: 💬 Every message", + ); + await expect(page.getByTestId("channel-general")).toContainText("💬"); + + await openEditDialog(page); + const policy = page.getByTestId("channel-management-agent-response"); + await expect(policy).toHaveAccessibleName( + "Agent replies: 💬 Every message", + ); + }); }); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index a2a92a8d39..f84581df41 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -485,6 +485,10 @@ test("manage sheet updates channel details through the relay", async ({ await expect( editDialog.getByTestId("channel-management-purpose"), ).toHaveCount(0); + await editDialog.getByTestId("channel-management-agent-response").click(); + await page + .getByTestId("channel-management-agent-response-option-all") + .click(); await editDialog.getByTestId("channel-management-save-changes").click(); await expect(editDialog).toHaveCount(0); @@ -519,6 +523,9 @@ test("manage sheet updates channel details through the relay", async ({ await expect( reopenedEditDialog.getByTestId("channel-management-purpose"), ).toHaveCount(0); + await expect( + reopenedEditDialog.getByTestId("channel-management-agent-response"), + ).toContainText("Every message"); }); test("manage sheet archive and unarchive survives a reload through the relay", async ({ diff --git a/migrations/0026_channel_agent_response_policy.sql b/migrations/0026_channel_agent_response_policy.sql new file mode 100644 index 0000000000..02f32e0db0 --- /dev/null +++ b/migrations/0026_channel_agent_response_policy.sql @@ -0,0 +1,7 @@ +-- Per-channel agent response policy. Mention-only preserves the existing +-- behavior for every current channel; owners/admins may opt a channel into +-- delivering untagged messages to all agent members. +ALTER TABLE channels + ADD COLUMN agent_response_policy TEXT NOT NULL DEFAULT 'mentions', + ADD CONSTRAINT channels_agent_response_policy_check + CHECK (agent_response_policy IN ('mentions', 'all')); diff --git a/schema/schema.sql b/schema/schema.sql index f5f32cc3e3..fd4b76f561 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -95,6 +95,8 @@ CREATE TABLE channels ( participant_hash BYTEA, ttl_seconds INT, ttl_deadline TIMESTAMPTZ, + agent_response_policy TEXT NOT NULL DEFAULT 'mentions' + CHECK (agent_response_policy IN ('mentions', 'all')), PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) );