From 6cdb7fef7bc6283898e2ed7264756d093cfbf865 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Sat, 1 Aug 2026 16:01:06 +0530 Subject: [PATCH 1/2] fix(buzz-acp): curated default kind list for SubscribeMode::All MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In All mode with no BUZZ_ACP_KINDS override, the ACP harness subscribed with a true wildcard (kinds: None). Any sibling agents sharing a channel then woke on each other's bookkeeping traffic — kind 7 (reactions, including the harness's own 👀/💬 lifecycle markers) and kind 5 (NIP-09 deletions) — and stormed on each other's wake events, burning tokens and flooding the channel with turns nobody asked for. When kinds_override is None, both resolve_channel_filters() and resolve_dynamic_channel_filter() now install the same curated default kind list: [9, 40002, 40003, 1059, 40007, 40100, 46010, 46020] i.e. stream chat (v1/v2/edit), gift-wrapped DMs, stream reminders, canvas, workflow approval requests, and workflow triggers — every kind an agent legitimately needs to wake on. Kinds 5 (NIP-09 deletion) and 7 (reaction) are excluded: they are harness bookkeeping, not user intent. Users who genuinely want the old wildcard can still set BUZZ_ACP_KINDS explicitly including 5 and 7; the override remains authoritative. Tests updated: - test_all_mode_wildcard now asserts the curated list (and asserts it excludes kinds 5 and 7). - New test_all_mode_dynamic_channel_matches_static locks the dynamic (instant-join) resolver to the same defaults as the bootstrap resolver, so instantly-joined channels behave identically to bootstrapped ones. - test_all_mode_with_kinds_override still passes unchanged. Fixes block/buzz#4086. Signed-off-by: Sarthak Singh --- crates/buzz-acp/src/config.rs | 107 +++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..403a4aba62 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1272,11 +1272,38 @@ pub fn resolve_channel_filters( } } SubscribeMode::All => { + // #4086: when no `BUZZ_ACP_KINDS` override is set, default to a curated + // list instead of a true wildcard. The previous wildcard behavior let + // two or more sibling agents storm indefinitely on each other's 👀/💬 + // (kind 7) and their NIP-09 deletions (kind 5) — harness bookkeeping, + // not user intent. The curated list below keeps every kind agents + // actually need to wake on (chat, DMs, stream reminders, canvas, + // workflow events) and excludes the reaction/bookkeeping kinds. + // + // Users can still opt into the genuine wildcard by setting + // `BUZZ_ACP_KINDS=` explicitly (including `5` and `7`); the override + // remains authoritative. + let default_kinds = config.kinds_override.clone().unwrap_or_else(|| { + use buzz_core::kind::{ + KIND_CANVAS, KIND_GIFT_WRAP, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_V2, KIND_WORKFLOW_TRIGGER, + }; + vec![ + KIND_STREAM_MESSAGE, // 9 + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_STREAM_MESSAGE_EDIT, // 40003 + KIND_GIFT_WRAP, // 1059 + KIND_STREAM_REMINDER, // 40007 + KIND_CANVAS, // 40100 + KIND_WORKFLOW_APPROVAL_REQUESTED, // 46010 + KIND_WORKFLOW_TRIGGER, // 46020 + ] + }); for ch in &target_channels { result.insert( *ch, ChannelFilter { - kinds: config.kinds_override.clone(), + kinds: Some(default_kinds.clone()), require_mention: false, }, ); @@ -1367,10 +1394,33 @@ pub fn resolve_dynamic_channel_filter( })), require_mention: !config.no_mention_filter, }), - SubscribeMode::All => Some(ChannelFilter { - kinds: config.kinds_override.clone(), - require_mention: false, - }), + SubscribeMode::All => { + // #4086: mirror of `resolve_channel_filters()` — default to the same + // curated kind list when no `BUZZ_ACP_KINDS` override is set, instead + // of a true wildcard that let sibling agents storm on bookkeeping + // reactions (kind 7) and NIP-09 deletions (kind 5). Override still + // reaches through untouched. + let default_kinds = config.kinds_override.clone().unwrap_or_else(|| { + use buzz_core::kind::{ + KIND_CANVAS, KIND_GIFT_WRAP, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_V2, KIND_WORKFLOW_TRIGGER, + }; + vec![ + KIND_STREAM_MESSAGE, // 9 + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_STREAM_MESSAGE_EDIT, // 40003 + KIND_GIFT_WRAP, // 1059 + KIND_STREAM_REMINDER, // 40007 + KIND_CANVAS, // 40100 + KIND_WORKFLOW_APPROVAL_REQUESTED, // 46010 + KIND_WORKFLOW_TRIGGER, // 46020 + ] + }); + Some(ChannelFilter { + kinds: Some(default_kinds), + require_mention: false, + }) + } SubscribeMode::Config => { // Same merge logic as resolve_channel_filters() Config branch: // evaluate ALL rules against this specific channel (including @@ -1427,6 +1477,11 @@ fn rule_applies_to_channel(rule: &SubscriptionRule, channel_id: Uuid) -> bool { mod tests { use super::*; use crate::filter::{ChannelScope, SubscriptionRule}; + use buzz_core::kind::{ + KIND_CANVAS, KIND_DELETION, KIND_GIFT_WRAP, KIND_REACTION, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, + KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_WORKFLOW_TRIGGER, + }; use clap::{Parser, ValueEnum}; /// Build a minimal Config for testing without CLI parsing. @@ -1768,14 +1823,52 @@ mod tests { assert_eq!(result.len(), 3); for ch in &channels { let f = result.get(ch).unwrap(); + // #4086: All mode with no kinds_override now defaults to a + // curated list (not a wildcard) to stop sibling agents storming + // on each other's bookkeeping kinds (5 = NIP-09 deletion, + // 7 = reaction). + let kinds = f + .kinds + .as_ref() + .expect("all mode with no override = curated default kinds"); + assert_eq!( + kinds, + &[ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_STREAM_MESSAGE_EDIT, + KIND_GIFT_WRAP, + KIND_STREAM_REMINDER, + KIND_CANVAS, + KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_WORKFLOW_TRIGGER, + ] + ); assert!( - f.kinds.is_none(), - "all mode with no override = wildcard kinds" + !kinds.contains(&KIND_DELETION) && !kinds.contains(&KIND_REACTION), + "curated defaults must exclude bookkeeping kinds 5 and 7" ); assert!(!f.require_mention); } } + #[test] + fn test_all_mode_dynamic_channel_matches_static() { + // #4086 lockstep: resolve_dynamic_channel_filter must emit the same + // curated default kinds as resolve_channel_filters, otherwise + // instantly-joined channels behave differently from bootstrapped + // ones. + let config = test_config(SubscribeMode::All); + let ch = Uuid::new_v4(); + + let dynamic = resolve_dynamic_channel_filter(&config, ch, &[]) + .expect("All mode should produce a dynamic filter"); + let statics = resolve_channel_filters(&config, &[ch], &[]); + let statik = statics.get(&ch).expect("All mode should produce a filter"); + assert_eq!(dynamic.kinds, statik.kinds); + assert_eq!(dynamic.require_mention, statik.require_mention); + } + #[test] fn test_all_mode_with_kinds_override() { let mut config = test_config(SubscribeMode::All); From 49991a2de760a41a3d56ab10a243a54f3605edb6 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Sat, 1 Aug 2026 16:10:33 +0530 Subject: [PATCH 2/2] fix(buzz-acp): anchor human-facing thread replies to the triggering message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a human talks with an agent inside a thread, the agent's reply previously flattened to the thread root, so a reply to a depth-2 nested question looked identical to a reply to the thread head. With multiple messages in flight the reader could not tell which message the agent was answering, and the agent felt less proactive. (#4072) Change resolve_reply_anchor() to return triggering_event_id in every human-facing case (thread or top-level). The CLI hint in format_context_hints() and the context-summary comment are updated to match; agent↔agent turns still receive no forced anchor, preserving deep-nesting coordination behavior. DM turns already anchored to the triggering event id via the is_dm branch; this change makes the non-DM branch match. Tests updated: - test_anchor_human_in_thread_uses_triggering_event — thread case now asserts TRIGGER_ID (was ROOT_ID) and renamed accordingly. - test_anchor_agent_sender_but_human_tagged_flattens — same rename. - test_anchor_unknown_identity_treated_as_human_fail_open — same rename. - test_human_thread_reply_anchors_to_root_not_triggering_or_parent — inverted; now asserts anchor is the triggering event id and NOT the thread root or intermediate parent. Regression test for #4072. - test_reply_instruction_allows_explicit_root_post_requests — updated to assert triggering-id anchor, while still requiring the explicit-root escape hint text. - test_reply_instruction_batched_last_event_is_threaded — asserts the anchor is the steering event id. - test_steer_cross_thread_reply_targets_steering_message — asserts the anchor is the *steering* message's own id (matching the human-facing supersede semantics), not the steering thread's root. - test_reply_instruction_present_for_channel_thread_reply — now the DM-mirror for non-DM; asserts triggering-id anchor. - New test_anchor_human_nested_reply_in_thread_uses_triggering_event — specifically pins the depth≥2 case (root≠parent) so a regression in nested threading is caught even if the simple flat-L1 case regresses unnoticed. Verification: cargo test -p buzz-acp --lib (663 passed), cargo clippy -p buzz-acp --all-targets -- -D warnings. Fixes block/buzz#4072. Signed-off-by: Sarthak Singh --- crates/buzz-acp/src/queue.rs | 126 +++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 43 deletions(-) diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..d81bdbcfb2 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1200,9 +1200,13 @@ fn turn_is_human_facing( /// Resolve the `--reply-to` anchor for a non-DM turn. /// -/// Returns `Some(id)` only for human-facing turns (see [`turn_is_human_facing`]): -/// - in a thread → the thread ROOT, keeping the reply flat at layer 1 -/// - top-level → the triggering event id, which becomes the new thread root +/// Returns `Some(triggering_event_id)` only for human-facing turns (see +/// [`turn_is_human_facing`]): +/// - in a thread → the triggering event id, so the agent's reply nests +/// directly under the message it is answering (clear to the human) — +/// see #4072 +/// - top-level → the triggering event id, which becomes the new thread +/// root /// /// Returns `None` for agent↔agent turns, leaving the agent free to nest deeply /// (intentional for agent coordination). @@ -1215,12 +1219,7 @@ fn resolve_reply_anchor( if !turn_is_human_facing(sender_pubkey, thread_tags, profile_lookup) { return None; } - Some( - thread_tags - .root_event_id - .clone() - .unwrap_or_else(|| triggering_event_id.to_string()), - ) + Some(triggering_event_id.to_string()) } /// Format a `[Context]` hints section based on event scope. @@ -1459,9 +1458,10 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec