From 962d5b7d4854fd040a425bf3718601367cf6d7f6 Mon Sep 17 00:00:00 2001 From: BachirSaaS <246364794+BachirSaaS@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:17:23 -0400 Subject: [PATCH] feat(forum): agents hear and answer forum posts and comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forum channels were invisible to the agent surface: buzz-acp's Mentions subscription only carried kinds 9/approval/reminder, so a forum post or comment that p-tagged an agent never started a turn (#2850, #5268, #4482, #5249) — and when an agent did reply via 'buzz messages send', the CLI published kind 9, which the forum view never renders: the reply vanished (#3828) and bare forum-channel sends were silently invisible (#5075). Read side: forum post (45001) and comment (45003) kinds join the Mentions default subscription in one shared default_mention_kinds() helper (startup rules, per-channel filters, dynamic channel filters). The dispatch path is already kind-agnostic, and forum comments carry NIP-10 thread tags, so the existing --reply-to reply instruction fires for them unchanged. Write side: 'buzz messages send' without an explicit --kind now resolves the channel's kind:39000 metadata; in a forum channel a bare send becomes a post (45001) and a --reply-to send becomes a comment (45003). The routing lookup fails open to stream behavior on any error so it can never block delivery. Agents that follow the existing --reply-to instruction now land as visible forum comments with no prompt changes. Signed-off-by: BachirSaaS --- crates/buzz-acp/src/config.rs | 68 +++++++++++++------- crates/buzz-acp/src/lib.rs | 12 ++-- crates/buzz-cli/src/commands/messages.rs | 81 +++++++++++++++++++++--- 3 files changed, 123 insertions(+), 38 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index b4d27903c62..ce9e767c3cb 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1332,16 +1332,33 @@ pub fn load_rules(path: &std::path::Path) -> Result, Confi Ok(config.rules) } +/// Default event kinds that can start a turn in Mentions subscribe mode. +/// +/// Channel chat (kind 9), workflow approvals, reminders, and forum posts and +/// comments (kinds 45001/45003). A `p` tag on a forum post or comment is +/// addressed content exactly like a stream mention — without these kinds the +/// agent never hears forum mentions at all (#2850, #5268, #4482, #5249). +pub(crate) fn default_mention_kinds() -> Vec { + use buzz_core::kind::{ + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, + KIND_WORKFLOW_APPROVAL_REQUESTED, + }; + + vec![ + KIND_STREAM_MESSAGE, + KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_STREAM_REMINDER, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + ] +} + /// Resolve per-channel NIP-01 filters from config + discovered channels. pub fn resolve_channel_filters( config: &Config, discovered_channels: &[Uuid], rules: &[SubscriptionRule], ) -> HashMap { - use buzz_core::kind::{ - KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, - }; - let target_channels: Vec = if let Some(ref overrides) = config.channels_override { overrides .iter() @@ -1356,13 +1373,10 @@ pub fn resolve_channel_filters( match config.subscribe_mode { SubscribeMode::Mentions => { - let kinds = config.kinds_override.clone().unwrap_or_else(|| { - vec![ - KIND_STREAM_MESSAGE, - KIND_WORKFLOW_APPROVAL_REQUESTED, - KIND_STREAM_REMINDER, - ] - }); + let kinds = config + .kinds_override + .clone() + .unwrap_or_else(default_mention_kinds); let require_mention = !config.no_mention_filter; for ch in &target_channels { result.insert( @@ -1440,10 +1454,6 @@ pub fn resolve_dynamic_channel_filter( channel_id: Uuid, rules: &[crate::filter::SubscriptionRule], ) -> Option { - use buzz_core::kind::{ - KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, - }; - // In Mentions/All mode, if the operator explicitly constrained channels // with --channels, only allow dynamic subscription to channels in that // allowlist. Config mode ignores --channels (per CLI contract) and uses @@ -1461,13 +1471,12 @@ pub fn resolve_dynamic_channel_filter( match config.subscribe_mode { SubscribeMode::Mentions => Some(ChannelFilter { - kinds: Some(config.kinds_override.clone().unwrap_or_else(|| { - vec![ - KIND_STREAM_MESSAGE, - KIND_WORKFLOW_APPROVAL_REQUESTED, - KIND_STREAM_REMINDER, - ] - })), + kinds: Some( + config + .kinds_override + .clone() + .unwrap_or_else(default_mention_kinds), + ), require_mention: !config.no_mention_filter, }), SubscribeMode::All => Some(ChannelFilter { @@ -1617,9 +1626,24 @@ mod tests { assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE)); assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED)); assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER)); + // Forum posts and comments carry `p`-tag mentions and must wake + // the agent like any other addressed content (#2850, #5268). + assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_POST)); + assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_COMMENT)); } } + #[test] + fn test_dynamic_mentions_mode_default_kinds() { + let config = test_config(SubscribeMode::Mentions); + let f = resolve_dynamic_channel_filter(&config, Uuid::new_v4(), &[]) + .expect("mentions mode should always resolve"); + let kinds = f.kinds.expect("should have kinds"); + assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE)); + assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_POST)); + assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_COMMENT)); + } + #[test] fn test_mentions_mode_custom_kinds() { let mut config = test_config(SubscribeMode::Mentions); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index cf723bcfd57..19dcf69e797 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -29,7 +29,6 @@ use acp::{AcpClient, EnvVar, McpServer}; use anyhow::{ensure, Context, Result}; use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, - KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, }; use buzz_core::observer::{ decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY, @@ -2737,13 +2736,10 @@ async fn run_harness( vec![SubscriptionRule { name: "mentions".into(), channels: filter::ChannelScope::All("all".into()), - kinds: config.kinds_override.clone().unwrap_or_else(|| { - vec![ - KIND_STREAM_MESSAGE, - KIND_WORKFLOW_APPROVAL_REQUESTED, - KIND_STREAM_REMINDER, - ] - }), + kinds: config + .kinds_override + .clone() + .unwrap_or_else(config::default_mention_kinds), require_mention: !config.no_mention_filter, filter: None, compiled_filter: None, diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d316..6f724a2955a 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -608,6 +608,34 @@ pub struct SendMessageParams { pub mentions: Vec, } +/// Read the channel type (`t` tag) from kind:39000 channel-metadata events. +/// Pure so the tag extraction is unit-testable without a relay. +fn channel_type_from_metadata(events: &[serde_json::Value]) -> Option { + events.first()?.get("tags")?.as_array()?.iter().find_map(|t| { + let t = t.as_array()?; + (t.first().and_then(|v| v.as_str()) == Some("t")) + .then(|| t.get(1).and_then(|v| v.as_str()).map(str::to_string)) + .flatten() + }) +} + +/// True when the channel's kind:39000 metadata declares `t = forum`. +/// Routing enrichment must never block delivery: any query error fails open +/// to `false` (stream behavior) — a genuinely unreachable relay surfaces on +/// the submit itself. +async fn channel_is_forum(client: &BuzzClient, channel_id: &str) -> Result { + let filter = serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + }); + let Ok(resp) = client.query(&filter).await else { + return Ok(false); + }; + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + Ok(channel_type_from_metadata(&events).as_deref() == Some("forum")) +} + pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, @@ -670,6 +698,15 @@ pub async fn cmd_send_message( format!("{}{media_content}", p.content) }; + // Forum channels are post/thread-scoped: a bare kind-9 send is accepted + // by the relay but never rendered in the forum view (#5075) — and agent + // replies there vanish (#3828). Auto-route when the caller didn't pick a + // kind explicitly: `--reply-to` → comment (45003), otherwise a new post + // (45001). An explicit `--kind` stays authoritative. + if p.kind.is_none() && channel_is_forum(client, &p.channel_id).await? { + p.kind = Some(if p.reply_to.is_some() { 45003 } else { 45001 }); + } + // Build thread ref if replying. `--reply-to` is the immediate parent; the // thread root is derived from the parent's NIP-10 tags via the relay. let thread_ref = if let Some(ref r) = p.reply_to { @@ -1084,11 +1121,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, - find_root_from_tags, format_events, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, resolve_thread_target, thread_ref_from_event, - thread_ref_from_parent_tags, BuzzClient, CliError, Uuid, + channel_id_from_event, channel_type_from_metadata, cmd_get_thread, cmd_send_message, + event_mention_pubkeys, find_root_from_tags, format_events, match_profiles_by_name, + merge_message_mentions, missing_members, normalize_explicit_mentions, + parse_member_pubkeys, resolve_names_to_pubkeys, resolve_thread_target, + thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1106,6 +1143,32 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; + #[test] + fn channel_type_from_metadata_reads_t_tag() { + let forum = json!({ + "kind": 39000, + "tags": [["h", "x"], ["t", "forum"], ["d", "11111111-2222-4333-8444-555555555555"]], + }); + assert_eq!( + channel_type_from_metadata(&[forum.clone()]).as_deref(), + Some("forum") + ); + + let stream = json!({ "kind": 39000, "tags": [["t", "stream"]] }); + assert_eq!( + channel_type_from_metadata(&[stream]).as_deref(), + Some("stream") + ); + + // No events (channel missing / relay unreachable) → fail open to stream. + assert_eq!(channel_type_from_metadata(&[]), None); + // Metadata without a `t` tag → fail open to stream. + assert_eq!( + channel_type_from_metadata(&[json!({ "kind": 39000, "tags": [["d", "x"]] })]), + None + ); + } + #[test] fn compact_event_format_remains_the_three_key_contract() { let normalized = serde_json::json!([{ @@ -1775,7 +1838,8 @@ mod tests { #[tokio::test] async fn cmd_send_message_skips_palette_query_when_no_colon_in_content() { // Content has no `:` at all — the palette query must be skipped - // entirely (zero RTTs), and the submitted event must have no emoji tags. + // entirely (only the forum-routing lookup may run), and the submitted + // event must have no emoji tags. let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); @@ -1785,8 +1849,9 @@ mod tests { assert_eq!( query_count.load(Ordering::Relaxed), - 0, - "palette must NOT be queried when content has no colon" + 1, + "no-colon content must run exactly one query (forum-routing lookup) \ + and never query the palette", ); // Submitted event must have no emoji tags.