Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 46 additions & 22 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1332,16 +1332,33 @@ pub fn load_rules(path: &std::path::Path) -> Result<Vec<SubscriptionRule>, 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<u32> {
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<Uuid, ChannelFilter> {
use buzz_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
};

let target_channels: Vec<Uuid> = if let Some(ref overrides) = config.channels_override {
overrides
.iter()
Expand All @@ -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(
Expand Down Expand Up @@ -1440,10 +1454,6 @@ pub fn resolve_dynamic_channel_filter(
channel_id: Uuid,
rules: &[crate::filter::SubscriptionRule],
) -> Option<ChannelFilter> {
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
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 4 additions & 8 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
81 changes: 73 additions & 8 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,34 @@ pub struct SendMessageParams {
pub mentions: Vec<String>,
}

/// 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<String> {
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<bool, CliError> {
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::Value> = 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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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!([{
Expand Down Expand Up @@ -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();

Expand All @@ -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.
Expand Down
Loading