From be01d89d6347a6939fe57be92b588f172d3cb02c Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 14:33:41 -0400 Subject: [PATCH 1/2] docs(agents): distinguish forum message kinds Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 4 ++++ crates/buzz-acp/src/lib.rs | 12 ++++++++++++ crates/buzz-cli/src/lib.rs | 2 +- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- desktop/src-tauri/src/managed_agents/nest/tests.rs | 8 ++++++++ desktop/src-tauri/src/managed_agents/nest_skill.md | 8 ++++++++ 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..16d4c5d7f1 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -61,6 +61,10 @@ 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. +### Forum Channels + +Forum channels are not stream channels, and the reply kind must match the thread root. Before replying, inspect the root kind in `[Context]`; if `[Context]` gives only a root event ID, fetch that root with `buzz messages thread --channel --event ` before choosing a kind. Use the stream default kind `9` for replies beneath a kind-`9` root, 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 a kind-`9` root. + ### 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/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..3441efc600 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3641,6 +3641,18 @@ 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_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("if `[Context]` gives only a root event ID")); + assert!(prompt.contains("buzz messages thread --channel --event ")); + assert!(prompt.contains("Never send kind `45003` beneath a kind-`9` root")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..79dbc802b8 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') diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index c8f008836d..fabf04814c 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 = 6; 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..e84f213f82 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,14 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[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("Never use kind `45003` beneath a kind-`9` root")); +} + #[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..264b167436 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -94,6 +94,14 @@ buzz messages send --channel \ --content "@Alice check this" --mention ``` +**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 a kind-`9` stream root, 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 a kind-`9` root. + +```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 `. From cbbd3ef67a1b9083f8c266d9bafdd11db8f00195 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 11:26:06 -0400 Subject: [PATCH 2/2] feat(acp): include thread root kind in context Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 2 +- crates/buzz-acp/src/config.rs | 26 ++- crates/buzz-acp/src/lib.rs | 33 ++- crates/buzz-acp/src/pool.rs | 141 ++++++++++-- crates/buzz-acp/src/queue.rs | 20 ++ crates/buzz-acp/src/setup_mode.rs | 210 +++++++++++++++--- .../src/managed_agents/nest/tests.rs | 5 +- .../src/managed_agents/nest_skill.md | 2 +- 8 files changed, 370 insertions(+), 69 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 16d4c5d7f1..b82eddd2ec 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -63,7 +63,7 @@ All replies and delegations — including task assignments to other agents — g ### Forum Channels -Forum channels are not stream channels, and the reply kind must match the thread root. Before replying, inspect the root kind in `[Context]`; if `[Context]` gives only a root event ID, fetch that root with `buzz messages thread --channel --event ` before choosing a kind. Use the stream default kind `9` for replies beneath a kind-`9` root, 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 a kind-`9` root. +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` and legacy kind-`40002` 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 kind-`9` or kind-`40002` roots. ### General 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 3441efc600..73a53a22b6 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; }); } } @@ -3649,9 +3666,11 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("kind `45001`")); assert!(prompt.contains("kind `45003`")); assert!(prompt.contains("stream default kind `9`")); - assert!(prompt.contains("if `[Context]` gives only a root event ID")); + assert!(prompt.contains("legacy kind-`40002` 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 a kind-`9` root")); + assert!(prompt.contains("Never send kind `45003` beneath kind-`9` or kind-`40002` roots")); } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d1e005cbcc..65abddb751 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2792,6 +2792,7 @@ async fn fetch_thread_context( .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_FORUM_COMMENT as u16), ]) .custom_tags(e_tag, [root_event_id]) .custom_tags(h_tag, [ch_str.as_str()]) @@ -2907,6 +2908,7 @@ fn parse_thread_response(json: serde_json::Value) -> Option messages, total, truncated, + root_kind: None, }) } @@ -2984,12 +2986,17 @@ fn parse_nostr_thread_response( ) -> Option { let events = json.as_array()?; let mut root_msg = None; + let mut root_kind = None; let mut reply_msgs = Vec::new(); for ev in events { 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 { reply_msgs.push(( @@ -3018,6 +3025,7 @@ fn parse_nostr_thread_response( messages, total, truncated: false, // query returns all within limit + root_kind, }) } @@ -3615,36 +3623,91 @@ 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. +/// 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 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, + }) }) + .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(thread_ref) = &thread_ref { + let filter = nostr::Filter::new().id(thread_ref.root_event_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 { + None + } + .unwrap_or_else(|| { + if matches!( + triggering_kind, + buzz_core::kind::KIND_FORUM_POST | buzz_core::kind::KIND_FORUM_COMMENT + ) { + buzz_core::kind::KIND_FORUM_POST + } else { + triggering_kind + } }); - 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}"); + + let builder_result = if root_kind == buzz_core::kind::KIND_FORUM_POST { + match thread_ref.as_ref() { + Some(thread_ref) => { + buzz_sdk::build_forum_comment(channel_id, content, thread_ref, &[], &[]) + } + None => { + tracing::warn!(channel = %channel_id, "failure notice: forum reply missing thread reference"); return; } - }; + } + } else { + buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) + }; + let builder = match builder_result { + Ok(builder) => builder, + Err(e) => { + tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + return; + } + }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { @@ -4005,6 +4068,40 @@ mod tests { assert!(with_core(None, None).is_none()); } + #[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 ctx = parse_nostr_thread_response(json, root_id).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!({ @@ -4031,6 +4128,7 @@ mod tests { messages, total, truncated, + .. } => { assert_eq!(messages.len(), 2); // root + 1 reply assert_eq!(total, 2); // 1 reply + 1 root @@ -4068,6 +4166,7 @@ mod tests { messages, total, truncated, + .. } => { assert_eq!(messages.len(), 2); assert_eq!(total, 11); // 10 replies + 1 root @@ -4121,6 +4220,7 @@ mod tests { messages, total, truncated, + .. } => { // Should be reversed to chronological order. assert_eq!(messages.len(), 2); @@ -4265,6 +4365,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 +463,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 +513,16 @@ pub(crate) fn should_nudge_for_event( true } +fn is_setup_message_kind(kind: u32) -> bool { + matches!( + kind, + KIND_STREAM_MESSAGE + | 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 +532,14 @@ 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_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 +603,97 @@ async fn handle_setup_membership( } } +fn setup_nudge_root_kind(triggering_event: &nostr::Event, looked_up_root_kind: Option) -> u32 { + looked_up_root_kind.unwrap_or_else(|| { + if matches!( + triggering_event.kind.as_u16() as u32, + KIND_FORUM_POST | KIND_FORUM_COMMENT + ) { + KIND_FORUM_POST + } else { + 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 = nostr::Filter::new().id(root_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 root_kind = setup_nudge_root_kind(triggering_event, looked_up_root_kind); + let event_builder = build_setup_nudge_event(channel_id, triggering_event, root_kind, payload)?; let signed = event_builder .sign_with_keys(keys) @@ -651,6 +713,80 @@ 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_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, Some(KIND_FORUM_POST)), + KIND_FORUM_POST + ); + assert_eq!(setup_nudge_root_kind(&trigger, None), KIND_STREAM_MESSAGE); + + 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, None), 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/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index e84f213f82..acac64e7ff 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -46,7 +46,10 @@ 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("Never use kind `45003` beneath a kind-`9` root")); + assert!(BUZZ_CLI_SKILL_MD.contains("legacy kind-`40002` stream roots")); + assert!( + BUZZ_CLI_SKILL_MD.contains("Never use kind `45003` beneath kind-`9` or kind-`40002` roots") + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 264b167436..5118a6e9c9 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -94,7 +94,7 @@ buzz messages send --channel \ --content "@Alice check this" --mention ``` -**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 a kind-`9` stream root, 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 a kind-`9` root. +**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` and legacy kind-`40002` 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 kind-`9` or kind-`40002` roots. ```bash buzz messages send --channel --kind 45001 --content "New discussion"