Skip to content
Open
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
45 changes: 45 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,24 @@ pub struct ChannelFilter {
pub require_mention: bool,
}

impl ChannelFilter {
/// Drop the relay-side `#p` mention filter when the channel is a DM.
///
/// A 1:1 DM is addressed to the agent by definition, so the relay must
/// deliver every message in it — not just `p`-tagged ones — otherwise the
/// agent never sees plain (un-@mentioned) DM messages (issue #2747). This
/// only widens *delivery*; per-event triggering is still gated by the
/// inbound author gate and `match_event`, which restrict DM turns to the
/// owner. Group channels keep their mention filter unchanged.
#[must_use]
pub fn with_dm_exemption(mut self, is_dm: bool) -> Self {
if is_dm {
self.require_mention = false;
}
self
}
}

#[derive(Debug)]
pub struct Config {
pub keys: Keys,
Expand Down Expand Up @@ -1330,6 +1348,33 @@ mod tests {
use crate::filter::{ChannelScope, SubscriptionRule};
use clap::{Parser, ValueEnum};

#[test]
fn test_with_dm_exemption_clears_mention_for_dm() {
let filter = ChannelFilter {
kinds: Some(vec![9]),
require_mention: true,
}
.with_dm_exemption(true);
assert!(
!filter.require_mention,
"DM must drop the relay-side #p mention filter"
);
assert_eq!(filter.kinds, Some(vec![9]), "kinds must be preserved");
}

#[test]
fn test_with_dm_exemption_preserves_mention_for_non_dm() {
let filter = ChannelFilter {
kinds: Some(vec![9]),
require_mention: true,
}
.with_dm_exemption(false);
assert!(
filter.require_mention,
"group channels keep the mention filter unchanged"
);
}

/// Build a minimal Config for testing without CLI parsing.
fn test_config(mode: SubscribeMode) -> Config {
Config {
Expand Down
83 changes: 73 additions & 10 deletions crates/buzz-acp/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,19 @@ const MAX_CONSECUTIVE_TIMEOUTS: u32 = 5;
/// 2. **kinds** — if non-empty, the event kind must be in the list.
/// 3. **require_mention** — if `true`, a `p` tag matching `agent_pubkey_hex` must
/// exist. Tag kind is checked via `tag.as_slice()` for stable, library-independent
/// access.
/// access. Bypassed when `mention_exempt` is set (see below).
/// 4. **filter** — if `Some`, the evalexpr expression must evaluate to `true`.
///
/// # DM mention exemption (`mention_exempt`)
///
/// A 1:1 DM is addressed to the agent by definition, so an owner message in a
/// DM should fire a turn without an explicit `@mention` (issue #2747). The
/// caller computes `mention_exempt` — true only for an **owner-authored** event
/// in a DM channel — and when set, step 3's mention check is skipped. It is
/// never set for sibling-agent or group-channel events, so the anti-loop
/// invariant (agents don't auto-reply to each other unmentioned) and the group
/// mention gate are both preserved. `kinds` and `filter` gating still apply.
///
/// # Fail-closed filter error handling
///
/// Any filter evaluation error — including timeout — causes the **entire
Expand All @@ -370,6 +380,7 @@ pub async fn match_event(
channel_id: uuid::Uuid,
rules: &[SubscriptionRule],
agent_pubkey_hex: &str,
mention_exempt: bool,
) -> Option<MatchedRule> {
let filter_ctx = FilterContext::from_event(event, channel_id);

Expand All @@ -387,7 +398,8 @@ pub async fn match_event(
// 3. Mention check — look for a `p` tag whose first element equals
// agent_pubkey_hex. Uses tag.as_slice() for stable, library-independent
// access — avoids relying on the Display impl of tag kind.
if rule.require_mention {
// Skipped when `mention_exempt` is set (owner message in a DM, #2747).
if rule.require_mention && !mention_exempt {
let mentioned = event.tags.iter().any(|tag| {
let s = tag.as_slice();
s.first().map(|k| k.as_str()) == Some("p")
Expand Down Expand Up @@ -601,7 +613,9 @@ mod tests {
),
];

let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
let matched = match_event(&event, channel_id, &rules, "", false)
.await
.unwrap();
assert_eq!(matched.rule_index, 0);
assert_eq!(matched.prompt_tag, "tag-first");
}
Expand Down Expand Up @@ -630,7 +644,9 @@ mod tests {
),
];

let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
let matched = match_event(&event, channel_id, &rules, "", false)
.await
.unwrap();
assert_eq!(matched.rule_index, 1);
assert_eq!(matched.prompt_tag, "matched");
}
Expand All @@ -653,16 +669,61 @@ mod tests {
)];

// Without mention — no match.
let result = match_event(&event_no_mention, channel_id, &rules, agent_pubkey).await;
let result = match_event(&event_no_mention, channel_id, &rules, agent_pubkey, false).await;
assert!(result.is_none());

// With mention — matches.
let matched = match_event(&event_with_mention, channel_id, &rules, agent_pubkey)
let matched = match_event(&event_with_mention, channel_id, &rules, agent_pubkey, false)
.await
.unwrap();
assert_eq!(matched.prompt_tag, "mentioned");
}

#[tokio::test]
async fn test_match_event_mention_exempt_bypasses_require_mention() {
// mention_exempt=true (owner message in a DM, #2747): a require_mention
// rule matches even without a `p` tag. Group/sibling paths never set the
// flag, so this does not weaken the mention gate elsewhere.
let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let event_no_mention = make_event(9, "hello");
let channel_id = any_channel();

let rules = vec![make_rule(
"mention-only",
ChannelScope::All("all".into()),
vec![],
true,
None,
Some("mentioned"),
)];

let matched = match_event(&event_no_mention, channel_id, &rules, agent_pubkey, true)
.await
.unwrap();
assert_eq!(matched.prompt_tag, "mentioned");
}

#[tokio::test]
async fn test_match_event_mention_exempt_still_honors_kind_filter() {
// The exemption only bypasses the mention check — kind gating still
// applies, so an owner DM of the wrong kind is not matched.
let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let event_kind_1 = make_event(1, "hello");
let channel_id = any_channel();

let rules = vec![make_rule(
"kind-9-mention",
ChannelScope::All("all".into()),
vec![9],
true,
None,
Some("mentioned"),
)];

let result = match_event(&event_kind_1, channel_id, &rules, agent_pubkey, true).await;
assert!(result.is_none());
}

#[tokio::test]
async fn test_match_event_no_match() {
let event = make_event(1, "hello");
Expand All @@ -677,7 +738,7 @@ mod tests {
None,
)];

let result = match_event(&event, channel_id, &rules, "").await;
let result = match_event(&event, channel_id, &rules, "", false).await;
assert!(result.is_none());
}

Expand Down Expand Up @@ -725,7 +786,9 @@ mod tests {
None, // no explicit tag
)];

let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
let matched = match_event(&event, channel_id, &rules, "", false)
.await
.unwrap();
assert_eq!(matched.prompt_tag, "my-rule");
}

Expand Down Expand Up @@ -755,7 +818,7 @@ mod tests {
];

// Must return None — not "catch-all".
let result = match_event(&event, channel_id, &rules, "").await;
let result = match_event(&event, channel_id, &rules, "", false).await;
assert!(
result.is_none(),
"filter error must fail closed, not fall through to next rule"
Expand All @@ -781,7 +844,7 @@ mod tests {
.store(MAX_CONSECUTIVE_TIMEOUTS, Ordering::Relaxed);

let rules = vec![rule];
let result = match_event(&event, channel_id, &rules, "").await;
let result = match_event(&event, channel_id, &rules, "", false).await;
assert!(result.is_none(), "disabled rule must return None");
}
}
Loading