diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..1b36d0ec32 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -222,7 +222,9 @@ Start with **N=2** for most deployments. Increase if queue depth grows under loa ## Forum Channels -By default, the ACP harness subscribes to stream message kinds (9, 46010, 40007). To receive forum events, opt in with `--kinds` and disable the mention filter (forum posts don't @mention agents): +By default, the ACP harness subscribes to mentioned stream messages, workflow approval requests, reminders, forum posts, and forum comments (kinds 9, 46010, 40007, 45001, and 45003). The default mention filter still applies, so unmentioned forum events are ignored. + +To receive every forum post, comment, and vote—including events that do not mention the agent—opt in to kind 45002 and disable the mention filter: **CLI flags:** ```bash @@ -246,7 +248,7 @@ Forum event kinds: - **45002** — Vote on a post or comment - **45003** — Comment reply on a forum post -> **Note:** Without `--no-mention-filter` (or `require_mention = false`), the default `subscribe=mentions` mode filters events that don't @mention the agent — forum posts will be invisible. +> **Note:** `--no-mention-filter` (or `require_mention = false`) is only needed for unmentioned events. Without it, the default `subscribe=mentions` mode still receives forum posts and comments that mention the agent. ## How It Works diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..c7e7994688 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -61,6 +61,14 @@ When in doubt, prefer the reply destination explicitly supplied in `[Context]`. All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it. +### Channel Creation + +When creating a channel, use `buzz channels create --type stream` unless the owner explicitly requests a forum. Forum channels are a preview feature and may be hidden for owners who have not enabled them; when the request is ambiguous, ask before using `--type forum`. + +### Forum Channels + +Forum channels are not stream channels, and the reply kind must match the thread root. Before replying, inspect the supplied `Thread root kind` in `[Context]`; only if the kind is unavailable, fetch the root with `buzz messages thread --channel --event ` before choosing a kind. Use the stream default kind `9` for replies beneath kind-`9`, legacy kind-`40002`, reminder kind-`40007`, kind-`40008` diff, and workflow approval kind-`46010` stream roots, even if the channel also hosts forum posts. For a new forum thread, send kind `45001`: `buzz messages send --channel --kind 45001 --content "..."`. Only beneath a kind-`45001` forum root, send replies as kind `45003` with the supplied `--reply-to `. Never send kind `45003` beneath stream roots. + ### General - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..903d866b0e 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1236,7 +1236,8 @@ pub fn resolve_channel_filters( rules: &[SubscriptionRule], ) -> HashMap { use buzz_core::kind::{ - KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, + KIND_WORKFLOW_APPROVAL_REQUESTED, }; let target_channels: Vec = if let Some(ref overrides) = config.channels_override { @@ -1256,6 +1257,8 @@ pub fn resolve_channel_filters( let kinds = config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, ] @@ -1338,7 +1341,8 @@ pub fn resolve_dynamic_channel_filter( rules: &[crate::filter::SubscriptionRule], ) -> Option { use buzz_core::kind::{ - KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, + KIND_WORKFLOW_APPROVAL_REQUESTED, }; // In Mentions/All mode, if the operator explicitly constrained channels @@ -1361,6 +1365,8 @@ pub fn resolve_dynamic_channel_filter( kinds: Some(config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, ] @@ -1507,11 +1513,27 @@ mod tests { assert!(f.require_mention, "mentions mode requires mention"); let kinds = f.kinds.as_ref().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)); assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED)); assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER)); } } + #[test] + fn test_dynamic_mentions_mode_default_kinds_include_forum_events() { + let config = test_config(SubscribeMode::Mentions); + let filter = resolve_dynamic_channel_filter(&config, Uuid::new_v4(), &[]) + .expect("dynamic channel should be subscribed"); + let kinds = filter.kinds.expect("mentions mode should constrain 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)); + assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED)); + assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER)); + } + #[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 403512a322..809327c775 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -21,8 +21,9 @@ use std::time::Duration; use acp::{AcpClient, EnvVar, McpServer}; use anyhow::Result; use buzz_core::kind::{ - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, - KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_FORUM_COMMENT, KIND_FORUM_POST, 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, @@ -1442,6 +1443,8 @@ async fn tokio_main() -> Result<()> { kinds: config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, ] @@ -3038,15 +3041,29 @@ fn spawn_failure_notice( content: String, ) { if let Some(rest) = rest_client { - let thread_tags = batch + let (thread_tags, triggering_kind, triggering_event_id) = batch .events .last() - .map(|be| queue::parse_thread_tags(&be.event)) + .map(|be| { + ( + queue::parse_thread_tags(&be.event), + be.event.kind.as_u16() as u32, + Some(be.event.id), + ) + }) .unwrap_or_default(); let rest = rest.clone(); let channel_id = batch.channel_id; tokio::spawn(async move { - pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await; + pool::post_failure_notice( + &rest, + channel_id, + &thread_tags, + triggering_kind, + triggering_event_id, + &content, + ) + .await; }); } } @@ -3641,6 +3658,31 @@ mod agent_draft_prompt_tests { .contains("add them explicitly with `buzz channels add-member` only when authorized")); assert!(prompt.contains("never changes membership automatically")); } + + #[test] + fn shared_base_prompt_defaults_channel_creation_to_stream() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz channels create --type stream")); + assert!(prompt.contains("unless the owner explicitly requests a forum")); + assert!(prompt.contains("may be hidden for owners who have not enabled them")); + assert!(prompt.contains("ask before using `--type forum`")); + } + + #[test] + fn shared_base_prompt_distinguishes_forum_kinds_from_stream_messages() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("Forum channels are not stream channels")); + assert!(prompt.contains("kind `45001`")); + assert!(prompt.contains("kind `45003`")); + assert!(prompt.contains("stream default kind `9`")); + assert!(prompt.contains( + "legacy kind-`40002`, reminder kind-`40007`, kind-`40008` diff, and workflow approval kind-`46010` stream roots" + )); + assert!(prompt.contains("inspect the supplied `Thread root kind` in `[Context]`")); + assert!(prompt.contains("only if the kind is unavailable")); + assert!(prompt.contains("buzz messages thread --channel --event ")); + assert!(prompt.contains("Never send kind `45003` beneath stream roots")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 158477c0af..9561ea76db 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2835,12 +2835,9 @@ where // Three filters: (1) root event by ID, (2) recent replies with #e=root + // #h=channel plus a sentinel, and (3) the agent's newest reply for pinning. - let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?); - let replies_filter = nostr::Filter::new() - .kinds([ - nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), - nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), - ]) + let root_filter = + root_message_filter(nostr::EventId::from_hex(root_event_id).ok()?, channel_id); + let replies_filter = thread_reply_filter() .custom_tags(e_tag, [root_event_id]) .custom_tags(h_tag, [ch_str.as_str()]) .limit(limit.saturating_add(1) as usize); @@ -3030,6 +3027,7 @@ fn parse_thread_response(json: serde_json::Value) -> Option messages, total, truncated, + root_kind: None, }) } @@ -3129,6 +3127,7 @@ fn parse_nostr_thread_response_with_meta( let events = json.as_array()?; let agent_pubkey_hex = agent_pubkey.to_hex(); let mut root_msg = None; + let mut root_kind = None; let mut reply_msgs = Vec::new(); let mut seen_reply_ids = HashSet::new(); @@ -3136,6 +3135,10 @@ fn parse_nostr_thread_response_with_meta( let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or(""); if let Some(msg) = json_to_context_message(ev) { if ev_id == root_event_id { + root_kind = ev + .get("kind") + .and_then(|value| value.as_u64()) + .and_then(|kind| u32::try_from(kind).ok()); root_msg = Some(msg); } else if seen_reply_ids.insert(ev_id.to_string()) { let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex); @@ -3201,6 +3204,7 @@ fn parse_nostr_thread_response_with_meta( messages, total, truncated, + root_kind, }, root_present, }) @@ -3800,33 +3804,130 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str } } -/// Best-effort: post a visible failure notice (kind:9) to a channel after a -/// batch is dead-lettered. Replies into the thread of `thread_tags` when the -/// triggering event was threaded. Errors are logged and swallowed — the -/// notice must never take down the main loop. +pub(crate) fn thread_reply_filter() -> nostr::Filter { + nostr::Filter::new().kinds([ + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_DIFF as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_FORUM_COMMENT as u16), + ]) +} + +pub(crate) fn root_message_filter(root_id: nostr::EventId, channel_id: Uuid) -> nostr::Filter { + use nostr::{Alphabet, SingleLetterTag}; + + nostr::Filter::new() + .id(root_id) + .kinds([ + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_REMINDER as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_DIFF as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_FORUM_POST as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED as u16), + ]) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_id.to_string(), + ) +} + +fn supported_root_kind(kind: u32) -> Option { + matches!( + kind, + buzz_core::kind::KIND_STREAM_MESSAGE + | buzz_core::kind::KIND_STREAM_MESSAGE_V2 + | buzz_core::kind::KIND_STREAM_REMINDER + | buzz_core::kind::KIND_STREAM_MESSAGE_DIFF + | buzz_core::kind::KIND_FORUM_POST + | buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED + ) + .then_some(kind) +} + +fn build_failure_notice( + channel_id: Uuid, + content: &str, + thread_ref: Option<&buzz_sdk::ThreadRef>, + root_kind: Option, +) -> Option { + let root_kind = root_kind?; + if root_kind == buzz_core::kind::KIND_FORUM_POST { + let thread_ref = thread_ref?; + buzz_sdk::build_forum_comment(channel_id, content, thread_ref, &[], &[]).ok() + } else { + buzz_sdk::build_message(channel_id, content, thread_ref, &[], false, &[]).ok() + } +} + +/// Best-effort: post a visible failure notice to a channel after a batch is +/// dead-lettered. Replies into the triggering thread and preserves forum +/// comment kind semantics. Errors are logged and swallowed — the notice must +/// never take down the main loop. pub(crate) async fn post_failure_notice( rest: &crate::relay::RestClient, channel_id: Uuid, thread_tags: &ThreadTags, + triggering_kind: u32, + triggering_event_id: Option, content: &str, ) { - let thread_ref = thread_tags.root_event_id.as_deref().and_then(|root| { - let root_id = nostr::EventId::from_hex(root).ok()?; - let parent_id = thread_tags - .parent_event_id - .as_deref() - .and_then(|p| nostr::EventId::from_hex(p).ok()) - .unwrap_or(root_id); - Some(buzz_sdk::ThreadRef { - root_event_id: root_id, - parent_event_id: parent_id, + let explicit_root_id = thread_tags + .root_event_id + .as_deref() + .and_then(|root| nostr::EventId::from_hex(root).ok()); + let thread_ref = explicit_root_id + .map(|root_id| { + let parent_id = thread_tags + .parent_event_id + .as_deref() + .and_then(|p| nostr::EventId::from_hex(p).ok()) + .unwrap_or(root_id); + buzz_sdk::ThreadRef { + root_event_id: root_id, + parent_event_id: parent_id, + } }) - }); + .or_else(|| { + triggering_event_id.map(|event_id| buzz_sdk::ThreadRef { + root_event_id: event_id, + parent_event_id: event_id, + }) + }); + let root_kind = if let Some(root_id) = explicit_root_id { + let filter = root_message_filter(root_id, channel_id); + match tokio::time::timeout(Duration::from_secs(5), rest.query(&[filter])).await { + Ok(Ok(json)) => json + .as_array() + .and_then(|events| events.first()) + .and_then(|event| event.get("kind")) + .and_then(serde_json::Value::as_u64) + .and_then(|kind| u32::try_from(kind).ok()), + Ok(Err(e)) => { + tracing::debug!(channel = %channel_id, "failure notice: root kind lookup failed: {e}"); + None + } + Err(_) => { + tracing::debug!(channel = %channel_id, "failure notice: root kind lookup timed out"); + None + } + } + } else { + supported_root_kind(triggering_kind) + }; + let Some(root_kind) = root_kind else { + tracing::warn!( + channel = %channel_id, + "failure notice: root kind is unknown; refusing to guess reply semantics" + ); + return; + }; + let builder = - match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { - Ok(b) => b, - Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + match build_failure_notice(channel_id, content, thread_ref.as_ref(), Some(root_kind)) { + Some(builder) => builder, + None => { + tracing::warn!(channel = %channel_id, "failure notice: build failed"); return; } }; @@ -3965,6 +4066,88 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + #[test] + fn failure_notice_builder_preserves_forum_kind_and_fails_closed() { + let channel_id = Uuid::new_v4(); + let root_event_id = nostr::EventId::from_slice(&[1; 32]).unwrap(); + let parent_event_id = nostr::EventId::from_slice(&[2; 32]).unwrap(); + let thread_ref = buzz_sdk::ThreadRef { + root_event_id, + parent_event_id, + }; + let keys = Keys::generate(); + + let forum = build_failure_notice( + channel_id, + "failed", + Some(&thread_ref), + Some(buzz_core::kind::KIND_FORUM_POST), + ) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + forum.kind.as_u16(), + buzz_core::kind::KIND_FORUM_COMMENT as u16 + ); + + let stream = build_failure_notice( + channel_id, + "failed", + Some(&thread_ref), + Some(buzz_core::kind::KIND_STREAM_REMINDER), + ) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + stream.kind.as_u16(), + buzz_core::kind::KIND_STREAM_MESSAGE as u16 + ); + + assert!(build_failure_notice(channel_id, "failed", Some(&thread_ref), None).is_none()); + assert!(build_failure_notice( + channel_id, + "failed", + None, + Some(buzz_core::kind::KIND_FORUM_POST), + ) + .is_none()); + + assert_eq!( + supported_root_kind(buzz_core::kind::KIND_STREAM_MESSAGE), + Some(buzz_core::kind::KIND_STREAM_MESSAGE) + ); + assert_eq!( + supported_root_kind(buzz_core::kind::KIND_FORUM_POST), + Some(buzz_core::kind::KIND_FORUM_POST) + ); + assert_eq!( + supported_root_kind(buzz_core::kind::KIND_FORUM_COMMENT), + None + ); + assert_eq!(supported_root_kind(49_999), None); + + let fallback_thread_ref = buzz_sdk::ThreadRef { + root_event_id, + parent_event_id: root_event_id, + }; + assert!(build_failure_notice( + channel_id, + "failed", + Some(&fallback_thread_ref), + supported_root_kind(buzz_core::kind::KIND_FORUM_COMMENT), + ) + .is_none()); + assert!(build_failure_notice( + channel_id, + "failed", + Some(&fallback_thread_ref), + supported_root_kind(49_999), + ) + .is_none()); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -4190,6 +4373,62 @@ mod tests { assert!(with_core(None, None).is_none()); } + #[test] + fn root_message_lookup_filter_specifies_supported_root_kinds() { + let root_id = nostr::EventId::from_hex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("valid event ID"); + + let channel_id = + Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("valid channel ID"); + let filter = serde_json::to_value(root_message_filter(root_id, channel_id)) + .expect("serialize filter"); + + assert_eq!(filter.get("ids"), Some(&json!([root_id.to_hex()]))); + assert_eq!( + filter.get("kinds"), + Some(&json!([9, 40002, 40007, 40008, 45001, 46010])) + ); + assert_eq!(filter.get("#h"), Some(&json!([channel_id.to_string()]))); + } + + #[test] + fn test_parse_nostr_thread_response_captures_root_kind() { + let root_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let json = json!([ + { + "id": root_id, + "kind": 45001, + "pubkey": "pub1", + "content": "forum root", + "created_at": 1710518400 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": 45003, + "pubkey": "pub2", + "content": "forum reply", + "created_at": 1710518460 + } + ]); + + let agent = Keys::generate(); + let ctx = parse_nostr_thread_response(json, root_id, 10, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + root_kind, + .. + } => { + assert_eq!(root_kind, Some(45_001)); + assert_eq!(messages[0].content, "forum root"); + } + _ => panic!("expected Thread context"), + } + } + #[test] fn test_parse_thread_response_basic() { let json = json!({ @@ -4216,6 +4455,7 @@ mod tests { messages, total, truncated, + .. } => { assert_eq!(messages.len(), 2); // root + 1 reply assert_eq!(total, 2); // 1 reply + 1 root @@ -4253,6 +4493,7 @@ mod tests { messages, total, truncated, + .. } => { assert_eq!(messages.len(), 2); assert_eq!(total, 11); // 10 replies + 1 root @@ -4306,6 +4547,7 @@ mod tests { messages, total, truncated, + .. } => { // Should be reversed to chronological order. assert_eq!(messages.len(), 2); @@ -4428,6 +4670,7 @@ mod tests { messages, total, truncated, + .. } => { assert_eq!(messages.len(), 3); // root + 2 displayed replies assert_eq!(total, 4); // root + displayed replies + sentinel @@ -4469,6 +4712,7 @@ mod tests { messages, total, truncated, + .. } => { assert_eq!(messages.len(), 2); assert_eq!(total, 2); @@ -4589,6 +4833,7 @@ mod tests { messages, total, truncated, + .. } => { assert!(truncated); assert_eq!(messages.len(), 3); @@ -4640,6 +4885,7 @@ mod tests { messages, total, truncated, + .. } => { assert!(truncated); assert_eq!(messages.len(), 2); @@ -4692,6 +4938,7 @@ mod tests { messages, total, truncated, + .. } => { assert!(truncated); assert_eq!(messages.len(), 3); @@ -4744,6 +4991,7 @@ mod tests { messages, total, truncated, + .. } => { assert!(truncated); assert_eq!(messages.len(), 3); @@ -4805,6 +5053,7 @@ mod tests { messages, total, truncated, + .. } => { assert!(truncated); assert_eq!(total, 4); @@ -4878,6 +5127,7 @@ mod tests { messages, total, truncated, + .. } => { assert!(truncated); assert_eq!(messages.len(), 3); @@ -4920,14 +5170,14 @@ mod tests { assert!(root.get("limit").is_none()); let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter"); - assert_eq!(replies.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(replies.get("kinds"), Some(&json!([9, 40002, 40008, 45003]))); assert_eq!(replies.get("#e"), Some(&json!([root_id]))); assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()]))); assert_eq!(replies.get("limit"), Some(&json!(reply_limit))); assert!(replies.get("authors").is_none()); let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter"); - assert_eq!(agent.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(agent.get("kinds"), Some(&json!([9, 40002, 40008, 45003]))); assert_eq!(agent.get("#e"), Some(&json!([root_id]))); assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()]))); assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()]))); @@ -4938,7 +5188,7 @@ mod tests { assert_eq!(filters.len(), 1, "count should query only matching replies"); let count = serde_json::to_value(&filters[0]).expect("serialize count filter"); - assert_eq!(count.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(count.get("kinds"), Some(&json!([9, 40002, 40008, 45003]))); assert_eq!(count.get("#e"), Some(&json!([root_id]))); assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()]))); assert_eq!(count.get("limit"), Some(&json!(0))); @@ -5016,6 +5266,7 @@ mod tests { }], total: 1, truncated: false, + root_kind: None, }; let pubkeys = collect_prompt_pubkeys(&batch, Some(&context)); diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..95400339fb 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -973,6 +973,8 @@ pub enum ConversationContext { messages: Vec, total: usize, truncated: bool, + /// Kind of the root event when it was returned by the context lookup. + root_kind: Option, }, /// DM conversation history. Dm { @@ -1237,6 +1239,7 @@ fn format_context_hints( is_dm: bool, has_conversation_context: bool, reply_anchor: Option<&str>, + thread_root_kind: Option, ) -> String { let channel_display = match channel_info { Some(ci) => format!("{} (#{channel_id})", ci.name), @@ -1267,6 +1270,9 @@ fn format_context_hints( // If this is a DM reply, include thread structural info as supplementary. if let Some(ref root) = thread_tags.root_event_id { s.push_str(&format!("\nThread root: {root}")); + if let Some(kind) = thread_root_kind { + s.push_str(&format!("\nThread root kind: {kind}")); + } if let Some(ref parent) = thread_tags.parent_event_id { if parent != root { s.push_str(&format!("\nParent: {parent}")); @@ -1289,6 +1295,9 @@ fn format_context_hints( Channel: {channel_display}\n\ Thread root: {root}" ); + if let Some(kind) = thread_root_kind { + s.push_str(&format!("\nThread root kind: {kind}")); + } if let Some(ref parent) = thread_tags.parent_event_id { if parent != root { s.push_str(&format!("\nParent: {parent}")); @@ -1323,6 +1332,7 @@ fn format_conversation_context( messages, total, truncated, + .. } => ("Thread Context", messages, total, truncated), ConversationContext::Dm { messages, @@ -1478,6 +1488,10 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec *root_kind, + _ => None, + }; sections.push(format_context_hints( batch.channel_id, args.channel_info, @@ -1485,6 +1499,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec } // Ignore non-message kinds (relay housekeeping, etc.). - if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED { + if !is_setup_message_kind(kind_u32) { continue; } @@ -463,6 +464,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Build and publish the setup nudge. if let Err(e) = publish_setup_nudge( &publisher, + &rest_client, &config.keys, buzz_event.channel_id, &buzz_event.event, @@ -512,6 +514,17 @@ pub(crate) fn should_nudge_for_event( true } +fn is_setup_message_kind(kind: u32) -> bool { + matches!( + kind, + KIND_STREAM_MESSAGE + | KIND_STREAM_REMINDER + | KIND_FORUM_POST + | KIND_FORUM_COMMENT + | KIND_WORKFLOW_APPROVAL_REQUESTED + ) +} + /// Build the subscription rules used in setup mode. /// /// Always uses "mentions" mode: setup mode must not react to every event. @@ -521,10 +534,15 @@ pub(crate) fn should_nudge_for_event( fn build_setup_subscription_rules(config: &Config) -> Vec { use crate::config::SubscribeMode; - let kinds = config - .kinds_override - .clone() - .unwrap_or_else(|| vec![KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED]); + let kinds = config.kinds_override.clone().unwrap_or_else(|| { + vec![ + KIND_STREAM_MESSAGE, + KIND_STREAM_REMINDER, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + KIND_WORKFLOW_APPROVAL_REQUESTED, + ] + }); match &config.subscribe_mode { // Config mode: load the actual rules, but they will be filtered by @@ -588,50 +606,104 @@ async fn handle_setup_membership( } } +fn setup_nudge_root_kind( + triggering_event: &nostr::Event, + has_explicit_root: bool, + looked_up_root_kind: Option, +) -> Option { + if has_explicit_root { + looked_up_root_kind + } else if triggering_event.kind.as_u16() as u32 == KIND_FORUM_COMMENT { + Some(KIND_FORUM_POST) + } else { + Some(triggering_event.kind.as_u16() as u32) + } +} + +fn build_setup_nudge_event( + channel_id: Uuid, + triggering_event: &nostr::Event, + root_kind: u32, + payload: &SetupPayload, +) -> Result { + use buzz_sdk::ThreadRef; + + let thread_tags = crate::queue::parse_thread_tags(triggering_event); + let root_id = if let Some(root_str) = &thread_tags.root_event_id { + nostr::EventId::from_hex(root_str) + .map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))? + } else { + triggering_event.id + }; + let thread_ref = ThreadRef { + root_event_id: root_id, + parent_event_id: root_id, + }; + let body = payload.nudge_body(); + let author_hex = triggering_event.pubkey.to_hex(); + + let builder = if root_kind == KIND_FORUM_POST { + buzz_sdk::build_forum_comment(channel_id, &body, &thread_ref, &[&author_hex], &[]) + } else { + buzz_sdk::build_message( + channel_id, + &body, + Some(&thread_ref), + &[&author_hex], + false, + &[], + ) + } + .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; + + Ok(builder) +} + /// Build and publish a setup nudge reply to the triggering event. /// /// Threading: flat reply to the thread root if one exists; otherwise reply -/// to the triggering event itself. P-tags the asker. +/// to the triggering event itself. P-tags the asker. Forum triggers produce +/// forum-comment nudges so the reply kind matches the forum thread. async fn publish_setup_nudge( publisher: &RelayEventPublisher, + rest: &crate::relay::RestClient, keys: &nostr::Keys, channel_id: Uuid, triggering_event: &nostr::Event, payload: &SetupPayload, ) -> Result<()> { - use buzz_sdk::ThreadRef; - - // Parse NIP-10 thread tags to determine reply target. let thread_tags = crate::queue::parse_thread_tags(triggering_event); - - let thread_ref = if let Some(root_str) = &thread_tags.root_event_id { - // Threaded event: reply flat to the root. - let root_id = nostr::EventId::from_hex(root_str) + let looked_up_root_kind = if let Some(root) = thread_tags.root_event_id.as_deref() { + let root_id = nostr::EventId::from_hex(root) .map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?; - Some(ThreadRef { - root_event_id: root_id, - parent_event_id: root_id, - }) + let filter = crate::pool::root_message_filter(root_id, channel_id); + match tokio::time::timeout(std::time::Duration::from_secs(5), rest.query(&[filter])).await { + Ok(Ok(json)) => json + .as_array() + .and_then(|events| events.first()) + .and_then(|event| event.get("kind")) + .and_then(serde_json::Value::as_u64) + .and_then(|kind| u32::try_from(kind).ok()), + Ok(Err(e)) => { + tracing::debug!(channel = %channel_id, "setup nudge: root kind lookup failed: {e}"); + None + } + Err(_) => { + tracing::debug!(channel = %channel_id, "setup nudge: root kind lookup timed out"); + None + } + } } else { - // Top-level event: reply to the triggering event. - Some(ThreadRef { - root_event_id: triggering_event.id, - parent_event_id: triggering_event.id, - }) + None }; - - let body = payload.nudge_body(); - let author_hex = triggering_event.pubkey.to_hex(); - - let event_builder = buzz_sdk::build_message( - channel_id, - &body, - thread_ref.as_ref(), - &[&author_hex], // p-tag the asker - false, - &[], - ) - .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; + let Some(root_kind) = setup_nudge_root_kind( + triggering_event, + thread_tags.root_event_id.is_some(), + looked_up_root_kind, + ) else { + anyhow::bail!("root kind is unknown; refusing to guess setup nudge reply semantics"); + }; + let event_builder = build_setup_nudge_event(channel_id, triggering_event, root_kind, payload)?; let signed = event_builder .sign_with_keys(keys) @@ -651,6 +723,88 @@ async fn publish_setup_nudge( mod tests { use super::*; + #[test] + fn setup_mode_accepts_stream_and_forum_message_kinds() { + assert!(is_setup_message_kind(KIND_STREAM_MESSAGE)); + assert!(is_setup_message_kind(KIND_STREAM_REMINDER)); + assert!(is_setup_message_kind(KIND_FORUM_POST)); + assert!(is_setup_message_kind(KIND_FORUM_COMMENT)); + assert!(is_setup_message_kind(KIND_WORKFLOW_APPROVAL_REQUESTED)); + assert!(!is_setup_message_kind(KIND_MEMBER_ADDED_NOTIFICATION)); + } + + #[test] + fn setup_nudge_prefers_looked_up_root_kind() { + let trigger = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), + "legacy malformed forum reply", + ) + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert_eq!( + setup_nudge_root_kind(&trigger, true, Some(KIND_FORUM_POST)), + Some(KIND_FORUM_POST) + ); + assert_eq!( + setup_nudge_root_kind(&trigger, false, None), + Some(KIND_STREAM_MESSAGE) + ); + assert_eq!(setup_nudge_root_kind(&trigger, true, None), None); + + let forum_comment = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_FORUM_COMMENT as u16), + "forum reply", + ) + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + assert_eq!( + setup_nudge_root_kind(&forum_comment, false, None), + Some(KIND_FORUM_POST) + ); + } + + #[test] + fn setup_nudge_kind_matches_trigger_thread_kind() { + let channel_id = Uuid::new_v4(); + let author = nostr::Keys::generate(); + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![], + }; + + for (trigger_kind, expected_nudge_kind) in [ + (KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE), + (KIND_FORUM_POST, KIND_FORUM_COMMENT), + (KIND_FORUM_COMMENT, KIND_FORUM_COMMENT), + ] { + let trigger = nostr::EventBuilder::new( + nostr::Kind::Custom(trigger_kind as u16), + "please wake up", + ) + .sign_with_keys(&author) + .unwrap(); + let root_kind = if trigger_kind == KIND_STREAM_MESSAGE { + KIND_STREAM_MESSAGE + } else { + KIND_FORUM_POST + }; + let nudge = build_setup_nudge_event(channel_id, &trigger, root_kind, &payload) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert_eq!(nudge.kind.as_u16() as u32, expected_nudge_kind); + let thread_tags = crate::queue::parse_thread_tags(&nudge); + let trigger_hex = trigger.id.to_hex(); + assert_eq!( + thread_tags.root_event_id.as_deref(), + Some(trigger_hex.as_str()) + ); + } + } + #[test] fn setup_payload_from_raw_returns_none_when_absent() { // None → Ok(None): normal startup, no setup payload. diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..05b5d903d4 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -348,7 +348,7 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --kind 45001 --content \"new forum thread\"\n buzz messages send --channel --kind 45003 --reply-to --content \"forum reply\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" )] Send { /// Channel UUID (from 'buzz channels list') @@ -544,7 +544,7 @@ pub enum ChannelsCmd { }, /// Create a new channel #[command( - after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override" + after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override\n\nUse stream unless the owner explicitly requests a forum. Forum channels are a preview feature and may be hidden for owners who have not enabled them." )] Create { /// Channel name diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index c8f008836d..45b7dc8912 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 5; +const NEST_SKILL_VERSION: u32 = 7; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a49..452e807fac 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,25 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[test] +fn nest_skill_defaults_channel_creation_to_stream() { + assert!(BUZZ_CLI_SKILL_MD.contains("buzz channels create --type stream")); + assert!(BUZZ_CLI_SKILL_MD.contains("unless the owner explicitly requests a forum")); + assert!(BUZZ_CLI_SKILL_MD.contains("may be hidden for owners who have not enabled them")); + assert!(BUZZ_CLI_SKILL_MD.contains("ask before using `--type forum`")); +} + +#[test] +fn nest_skill_contains_forum_workflow() { + assert!(BUZZ_CLI_SKILL_MD.contains("forum root as kind `45001`")); + assert!(BUZZ_CLI_SKILL_MD.contains("forum reply as kind `45003`")); + assert!(BUZZ_CLI_SKILL_MD.contains("reply kind must match the thread root")); + assert!(BUZZ_CLI_SKILL_MD.contains( + "legacy kind-`40002`, reminder kind-`40007`, kind-`40008` diff, and workflow approval kind-`46010` stream roots" + )); + assert!(BUZZ_CLI_SKILL_MD.contains("Never use kind `45003` beneath stream roots")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d..c6f31b5ca4 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -94,6 +94,16 @@ buzz messages send --channel \ --content "@Alice check this" --mention ``` +**Channel creation:** Use `buzz channels create --type stream` unless the owner explicitly requests a forum. Forum channels are a preview feature and may be hidden for owners who have not enabled them; ask before using `--type forum` when the request is ambiguous. + +**Forum messages:** Forum roots and comments are distinct from stream messages, and the reply kind must match the thread root. Check the root kind before replying: omit `--kind` (or use kind `9`) beneath kind-`9`, legacy kind-`40002`, reminder kind-`40007`, kind-`40008` diff, and workflow approval kind-`46010` stream roots, even in a forum-capable channel. Send a forum root as kind `45001`; only beneath a kind-`45001` forum root, send a forum reply as kind `45003` with `--reply-to `. Never use kind `45003` beneath stream roots. + +```bash +buzz messages send --channel --kind 45001 --content "New discussion" +buzz messages send --channel --kind 45003 \ + --reply-to --content "Reply" +``` + ## DM Management `dms hide --channel ` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey `.