diff --git a/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md index d248806e4e..152de71864 100644 --- a/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md +++ b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md @@ -7,7 +7,7 @@ Your `shell` tool has the `buzz` CLI on PATH, already authenticated as you. Nothing you write is visible to anyone unless you publish it: every message — step assignments, verification requests, the final `DONE:` — must be sent with -`buzz messages send --channel --content `. Your team, your +`buzz messages send --audience agents --channel --content `. Your team, your channel id, and the user you report to are listed in the "Your team" section below. Your turn is not complete until you have published your message. diff --git a/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md index c1dbd7b795..4baa7a25d9 100644 --- a/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md +++ b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md @@ -9,7 +9,7 @@ Your `shell` tool has the `buzz` CLI on PATH, already authenticated as you. Nothing you write is visible to anyone unless you publish it: every message — step assignments, verification requests, the final `DONE:` report — must be sent with -`buzz messages send --channel --content `. Your turn is +`buzz messages send --audience agents --channel --content `. Your turn is not complete until you have published your message. Do not use the shell for task work — that is your workers' job. diff --git a/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md b/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md index 442d7b874d..cedcee4fc6 100644 --- a/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md +++ b/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md @@ -6,7 +6,7 @@ You work directly in the task environment: your `shell` tool runs commands in it, and your file tools read and edit its files. The same shell has the `buzz` CLI on PATH, already authenticated as you; reports go to the channel with -`buzz messages send --channel --content `. Your team and your +`buzz messages send --audience agents --channel --content `. Your team and your channel id are listed in the "Your team" section below. Your turn is not complete until you have published your report. diff --git a/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md b/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md index 49a9125a17..818aca2700 100644 --- a/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md +++ b/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md @@ -8,7 +8,7 @@ You work directly in the task environment: your `shell` tool runs commands in it, and your file tools read and edit its files. The same shell has the `buzz` CLI on PATH, already authenticated as you; reports go to the channel with -`buzz messages send --channel --content `. Your turn is +`buzz messages send --audience agents --channel --content `. Your turn is not complete until you have published your report. The orchestrator only wakes for messages that @mention it. Every report diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index a29b86e731..ea2cc5dfbc 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -833,6 +833,8 @@ class BuzzContainerRuntime: args = [ "messages", "send", + "--audience", + "everyone", "--channel", trial.channel_id, "--content", diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 7e7b3e5ff0..4ede28736e 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -174,7 +174,7 @@ Under the default `channel` policy, a session scope is the whole channel, so the Owner control commands must be kind:9 stream messages from the owner, must have body exactly `!cancel`, `!rotate`, or `!shutdown` after trimming, and must mention this agent with a separate `p` tag. They are consumed by the harness instead of being forwarded to the agent. An inline `@Name` changes the body and does not match. With the Buzz CLI, target a thread while preserving the exact command body by passing the mention separately: ```bash -buzz messages send --channel --reply-to \ +buzz messages send --audience everyone --channel --reply-to \ --mention --content '!cancel' ``` diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 26aada32db..c3e3709c7a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -19,6 +19,14 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. +## Message audience + +Every `buzz messages send` call requires `--audience agents|everyone`. +Use `agents` for agent-to-agent delegation, coordination, and callbacks intended for an agent. +Use `everyone` for human-facing answers, decisions, progress, and blockers. Never hide a request for human action as coordination. +Always use `--audience agents` when coordinating with other agent collaborators, or even just when posting technical details to the chat for a human audience. Keep messages on `--audience everyone` short and to the point. +Both remain readable by channel members. Audience does not change mentions, threading, permissions, or publish outside the channel. + ## Projects A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Buzz Desktop — never do that for work that already has a project. diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c..962de1ef5c 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -29,9 +29,9 @@ All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=use export BUZZ_RELAY_URL="https://relay.example.com" # Messages -buzz messages send --channel --content "Hello" -buzz messages send --channel --content "Reply" --reply-to --broadcast -buzz messages send --channel --content - < message.md # read body from stdin +buzz messages send --audience everyone --channel --content "Hello" +buzz messages send --audience everyone --channel --content "Reply" --reply-to --broadcast +buzz messages send --audience everyone --channel --content - < message.md # read body from stdin buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages thread --link 'buzz://message?channel=&id=&thread=' diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index b7fa06d203..0232aafc68 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -190,27 +190,27 @@ buzz canvas get --channel "$CHANNEL_ID" ```bash # messages send -MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) +MSG=$(buzz messages send --audience everyone --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) echo "$MSG" EVENT_ID=$(echo "$MSG" | jq -r '.event_id') # messages send with reply + broadcast -REPLY=$(buzz messages send --channel "$CHANNEL_ID" --content "Reply" \ +REPLY=$(buzz messages send --audience everyone --channel "$CHANNEL_ID" --content "Reply" \ --reply-to "$EVENT_ID" --broadcast | jq .) echo "$REPLY" REPLY_ID=$(echo "$REPLY" | jq -r '.event_id') # messages send with mentions — @name in content is auto-resolved, no flag needed -buzz messages send --channel "$CHANNEL_ID" --content "Hey @someone" | jq . +buzz messages send --audience everyone --channel "$CHANNEL_ID" --content "Hey @someone" | jq . # messages send with NIP-27 nostr:npub1… inline mention — auto-resolved to p-tag -buzz messages send --channel "$CHANNEL_ID" \ +buzz messages send --audience everyone --channel "$CHANNEL_ID" \ --content "Check with nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg on this" | jq . # messages send from stdin — safe path for content with shell metacharacters # (backticks, $vars, code blocks) that would otherwise be expanded by the shell. echo 'Body with `backticks` and $vars stays literal.' \ - | buzz messages send --channel "$CHANNEL_ID" --content - | jq . + | buzz messages send --audience everyone --channel "$CHANNEL_ID" --content - | jq . # messages get buzz messages get --channel "$CHANNEL_ID" | jq . @@ -273,7 +273,7 @@ echo "diff content" | buzz messages send-diff \ ```bash # Send a message to react to -REACT_MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "React to this") +REACT_MSG=$(buzz messages send --audience everyone --channel "$CHANNEL_ID" --content "React to this") REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id') # reactions add @@ -436,7 +436,7 @@ buzz feed get --limit 5 | jq . ```bash # Send a forum post (kind 45001) to the forum channel -FORUM_POST=$(buzz messages send --channel "$FORUM_ID" \ +FORUM_POST=$(buzz messages send --audience everyone --channel "$FORUM_ID" \ --content "Forum post for vote testing" --kind 45001 | jq .) echo "$FORUM_POST" FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id') diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d31..3d7deeacbc 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -599,6 +599,7 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri } pub struct SendMessageParams { + pub audience: buzz_sdk::MessageAudience, pub channel_id: String, pub content: String, pub kind: Option, @@ -742,12 +743,17 @@ pub async fn cmd_send_message( } }; + let builder = p + .audience + .apply(builder) + .map_err(|e| CliError::Other(format!("audience tag failed: {e}")))?; let event = client.sign_event(builder)?; let emitted_mentions = event_mention_pubkeys(&event); let resp = client.submit_event(event).await?; let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) .unwrap_or_else(|_| serde_json::json!({ "response": resp })); if let Some(object) = output.as_object_mut() { + object.insert("audience".into(), serde_json::json!(p.audience.as_str())); object.insert( "mention_pubkeys".into(), serde_json::json!(emitted_mentions), @@ -938,6 +944,7 @@ pub async fn dispatch( use crate::MessagesCmd; match cmd { MessagesCmd::Send { + audience, channel, content, kind, @@ -949,6 +956,7 @@ pub async fn dispatch( cmd_send_message( client, SendMessageParams { + audience, channel_id: channel, content, kind, @@ -1710,6 +1718,7 @@ mod tests { fn send_params(content: &str) -> super::SendMessageParams { super::SendMessageParams { + audience: buzz_sdk::MessageAudience::Everyone, channel_id: SEND_TEST_CHANNEL.to_string(), content: content.to_string(), kind: None, @@ -1720,6 +1729,34 @@ mod tests { } } + #[tokio::test] + async fn cmd_send_message_signs_explicit_audience_for_each_supported_kind() { + for kind in [None, Some(9), Some(45001)] { + for audience in [ + buzz_sdk::MessageAudience::Agents, + buzz_sdk::MessageAudience::Everyone, + ] { + let (url, _, captured) = fake_send_relay("[]".into()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let mut params = send_params("audience canary"); + params.kind = kind; + params.audience = audience; + cmd_send_message(&client, params).await.unwrap(); + let raw = captured.lock().unwrap(); + let event: nostr::Event = + serde_json::from_str(&raw.as_ref().unwrap().body).unwrap(); + event.verify().unwrap(); + let tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice()[0] == "audience") + .collect(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].as_slice(), &["audience", audience.as_str()]); + } + } + } + #[tokio::test] async fn cmd_send_message_attaches_emoji_tags_for_known_shortcodes() { // Content contains `:wave:` which resolves in the palette. diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089..74dff48880 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -410,9 +410,12 @@ 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 --audience everyone --channel --content \"hello\"\n buzz messages send --audience everyone --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --audience everyone --channel --content -" )] Send { + /// Required intent: agents (coordination) or everyone (normal conversation). Not an access restriction. + #[arg(long)] + audience: buzz_sdk::MessageAudience, /// Channel UUID (from 'buzz channels list') #[arg(long)] channel: String, @@ -425,7 +428,7 @@ pub enum MessagesCmd { /// Event ID to reply to (creates a thread) #[arg(long)] reply_to: Option, - /// Also publish to the Nostr network + /// Also show a depth-1 reply in the channel timeline #[arg(long, default_value_t = false)] broadcast: bool, /// Attach file(s) — uploads and includes as imeta tags @@ -2268,6 +2271,28 @@ mod tests { } } + #[test] + fn message_send_requires_an_explicit_audience() { + let args = [ + "buzz", + "messages", + "send", + "--channel", + "123e4567-e89b-12d3-a456-426614174000", + "--content", + "hello", + ]; + assert!(Cli::try_parse_from(args).is_err()); + for audience in ["agents", "everyone"] { + let mut explicit = args.to_vec(); + explicit.extend(["--audience", audience]); + assert!(Cli::try_parse_from(explicit).is_ok()); + } + let mut invalid = args.to_vec(); + invalid.extend(["--audience", "broadcast"]); + assert!(Cli::try_parse_from(invalid).is_err()); + } + /// Smoke test: CLI definition is valid and parseable. #[test] fn cli_definition_is_valid() { diff --git a/crates/buzz-sdk/src/audience.rs b/crates/buzz-sdk/src/audience.rs new file mode 100644 index 0000000000..5b48737e54 --- /dev/null +++ b/crates/buzz-sdk/src/audience.rs @@ -0,0 +1,96 @@ +//! Explicit presentation intent for channel messages. This is not an ACL. + +/// Who a message is intended to address, independently of its recipients. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageAudience { + /// Agent-to-agent coordination, still readable by channel members. + Agents, + /// Ordinary human-facing conversation (not a mass notification). + Everyone, +} + +impl MessageAudience { + /// Canonical signed wire value. + pub fn as_str(self) -> &'static str { + match self { + Self::Agents => "agents", + Self::Everyone => "everyone", + } + } + + /// Add the audience declaration to a newly constructed message builder. + /// Call once, before signing; do not use to reclassify existing events. + pub fn apply( + self, + builder: nostr::EventBuilder, + ) -> Result { + let tag = nostr::Tag::parse(["audience", self.as_str()]) + .map_err(|e| crate::SdkError::InvalidTag(e.to_string()))?; + Ok(builder.tag(tag)) + } +} + +impl std::str::FromStr for MessageAudience { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "agents" => Ok(Self::Agents), + "everyone" => Ok(Self::Everyone), + _ => Err("audience must be agents or everyone".into()), + } + } +} + +/// Only a single well-formed declaration on supported message kinds opts into coordination. +/// Unknown/ambiguous tags fail open for visibility, never hide legacy messages. +pub fn is_agent_coordination(kind: u16, tags: &[Vec]) -> bool { + if !matches!(kind, 9 | 45001 | 45003) { + return false; + } + let mut declarations = tags + .iter() + .filter(|tag| tag.first().is_some_and(|name| name == "audience")); + let Some(tag) = declarations.next() else { + return false; + }; + declarations.next().is_none() && tag.len() == 2 && tag[1] == "agents" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audience_survives_signing_without_changing_message_routing() { + let keys = nostr::Keys::generate(); + let channel = uuid::Uuid::new_v4(); + let parent = nostr::EventId::all_zeros(); + let thread = crate::ThreadRef { + root_event_id: parent, + parent_event_id: parent, + }; + for audience in [MessageAudience::Agents, MessageAudience::Everyone] { + let builder = + crate::build_message(channel, "hello", Some(&thread), &[], false, &[], &[]) + .unwrap(); + let event = audience + .apply(builder) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + event.verify().unwrap(); + let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect(); + assert_eq!(tags.iter().filter(|t| t[0] == "audience").count(), 1); + assert!(tags + .iter() + .any(|t| t[0] == "audience" && t[1] == audience.as_str())); + assert!(tags + .iter() + .any(|t| t[0] == "h" && t[1] == channel.to_string())); + assert!(tags.iter().any(|t| t[0] == "e" && t[3] == "reply")); + assert_eq!(event.kind.as_u16(), 9); + } + assert!("broadcast".parse::().is_err()); + } +} diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 845505c56d..16f7a4f38f 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -12,6 +12,9 @@ //! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`. //! No keys are held here. No network calls are made. +pub mod audience; +pub use audience::MessageAudience; + pub mod broker; pub mod builders; pub mod mentions; diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 01f7622915..0b49887311 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -90,7 +90,7 @@ Write commands are unaffected. `--format json` (default) returns full fields. **Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. ```bash -buzz messages send --channel \ +buzz messages send --audience everyone --channel \ --content "@Alice check this" --mention ``` diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs index d0e2de827a..dae83e81b0 100644 --- a/desktop/src-tauri/src/unread_catch_up.rs +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -264,6 +264,9 @@ fn classify_batch( for item in &fetched { let mut discovered = DiscoveredRoots::default(); for event in &item.events { + if buzz_sdk_pkg::audience::is_agent_coordination(event.kind, &event.tags) { + continue; + } if event.pubkey.eq_ignore_ascii_case(&self_pubkey) { let reference = thread_reference(&event.tags); if let Some(root_id) = reference.root_id { @@ -416,6 +419,9 @@ fn should_notify( participated: &HashSet, authored: &HashSet, ) -> bool { + if buzz_sdk_pkg::audience::is_agent_coordination(event.kind, &event.tags) { + return false; + } if has_exact_tag(&event.tags, "broadcast", "1") || has_tag_value(&event.tags, "p", self_pubkey) { return true; @@ -491,6 +497,67 @@ mod tests { } } + #[test] + fn coordination_does_not_reappear_as_unread_after_restart() { + let req = request(); + let fetched = vec![FetchedChannel { + order: 0, + channel: CatchUpChannel { + id: "ch".into(), + channel_type: "stream".into(), + name: "Ch".into(), + read_at: Some(9), + }, + events: vec![ + event( + "coord", + "other", + 10, + &[ + &["audience", "agents"], + &["p", "self"], + &["broadcast", "1"], + &["e", "root", "", "reply"], + ], + ), + event( + "answer", + "other", + 11, + &[&["audience", "everyone"], &["p", "self"]], + ), + event("legacy", "other", 12, &[]), + event( + "ambiguous", + "other", + 13, + &[&["audience", "agents"], &["audience", "everyone"]], + ), + ], + }]; + let result = classify_batch(&req, fetched, &HashMap::new()); + let ChannelResult::Success { + observed_events, + discovered, + max_trigger, + activity_rows, + .. + } = &result[0] + else { + panic!("expected success") + }; + assert_eq!( + observed_events + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["answer", "legacy", "ambiguous"] + ); + assert!(discovered.mentioned.is_empty()); + assert!(activity_rows.is_empty()); + assert_eq!(*max_trigger, 13); + } + #[test] fn pass_one_history_changes_later_classification() { let req = request(); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 17a52d898d..3a97673966 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -351,7 +351,7 @@ rather than offer an ineffective command. An authorized owner can instead use the CLI with the channel and target thread root: ```sh -buzz messages send --channel --reply-to \ +buzz messages send --audience everyone --channel --reply-to \ --mention --content '!cancel' ``` diff --git a/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts b/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts index 987d2aacda..fd6d023c83 100644 --- a/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts +++ b/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; import { buildDescendantStatsByMessageId } from "@/features/messages/lib/threadPanel"; import type { TimelineMessage } from "@/features/messages/types"; @@ -51,6 +52,7 @@ export function computeThreadReplyUnreadCounts(params: { if (!subtree.has(message.id)) return false; if (currentPubkey && message.pubkey === currentPubkey) return false; if (isForcedUnread(message.id)) return true; + if (isAgentCoordination(message)) return false; const readAt = getReadAt(message.id); return readAt === null || message.createdAt > readAt; }) diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 3bb9fe39fd..eb33e8fed6 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -294,6 +295,7 @@ export function useLiveChannelUpdates( // own outgoing messages should never make a channel unread, and // reactions / edits / system messages aren't "new content". const isExternalTriggerEvent = + !isAgentCoordination(event) && isUnreadTriggerKind && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey); diff --git a/desktop/src/features/communities/communityUnreadObserver.ts b/desktop/src/features/communities/communityUnreadObserver.ts index e729831eac..8831fc9070 100644 --- a/desktop/src/features/communities/communityUnreadObserver.ts +++ b/desktop/src/features/communities/communityUnreadObserver.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { forcedUnreadMarker, @@ -289,8 +290,10 @@ export async function fetchCommunityUnread(args: { ); } - mentionCount += mentionEvents.filter((event) => - isUnreadExternalEvent(event, readState, readAt, normalizedPubkey), + mentionCount += mentionEvents.filter( + (event) => + !isAgentCoordination(event) && + isUnreadExternalEvent(event, readState, readAt, normalizedPubkey), ).length; } diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 29543328fa..22ac2cde54 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; import { resolveUserLabel, type UserProfileLookup, @@ -515,6 +516,7 @@ export function buildInboxItems({ >(); for (const item of feedItems) { + if (isAgentCoordination(item)) continue; const threadKey = getInboxThreadKey(item, channelById); const group = threadGroups.get(threadKey) ?? { items: [], diff --git a/desktop/src/features/messages/lib/messageAudience.test.mjs b/desktop/src/features/messages/lib/messageAudience.test.mjs new file mode 100644 index 0000000000..1eff97d522 --- /dev/null +++ b/desktop/src/features/messages/lib/messageAudience.test.mjs @@ -0,0 +1,113 @@ +import { buildInboxItems } from "../../home/lib/inbox.ts"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isAgentCoordination, messageAudience } from "./messageAudience.ts"; +import { + shouldNotifyForEvent, + hasMentionForEvent, + isHighPriorityEventForUser, +} from "../../notifications/lib/shouldNotify.ts"; +import { eligibleFeedNotificationItems } from "../../notifications/lib/feed.ts"; +import { buildHomeBadgeFeedItems } from "../../notifications/lib/homeBadge.ts"; + +const options = { + participatedRootIds: new Set(["root"]), + followedRootIds: new Set(["root"]), + authoredRootIds: new Set(["root"]), +}; +const event = (tags, kind = 9) => ({ + id: "a", + kind, + pubkey: "agent", + created_at: 100, + content: "hello", + tags, +}); + +test("only exactly one recognized declaration opts into coordination", () => { + assert.equal(messageAudience([["audience", "everyone"]]), "everyone"); + assert.equal(isAgentCoordination(event([["audience", "agents"]])), true); + for (const tags of [ + [], + [["audience"]], + [["audience", "future"]], + [["audience", "agents", "extra"]], + [ + ["audience", "agents"], + ["audience", "everyone"], + ], + [ + ["audience", "agents"], + ["audience", "agents"], + ], + ]) { + assert.equal(messageAudience(tags), null); + assert.equal(isAgentCoordination(event(tags)), false); + assert.equal(shouldNotifyForEvent(event(tags), "human", options), true); + } + assert.equal(isAgentCoordination(event([["audience", "agents"]], 7)), false); +}); + +test("coordination does not create human mentions, priority or thread alerts", () => { + for (const extra of [ + [], + [["p", "human"]], + [["broadcast", "1"]], + [["e", "root", "", "reply"]], + [ + ["p", "human"], + ["broadcast", "1"], + ["e", "root", "", "reply"], + ], + ]) { + const message = event([["audience", "agents"], ...extra]); + assert.equal(shouldNotifyForEvent(message, "human", options), false); + assert.equal(hasMentionForEvent(message, "human"), false); + assert.equal(isHighPriorityEventForUser(message, "human"), false); + // No mutation of routing metadata. + assert.deepEqual(message.tags, [["audience", "agents"], ...extra]); + assert.equal( + shouldNotifyForEvent( + event([["audience", "everyone"], ...extra]), + "human", + options, + ), + true, + ); + } +}); + +test("coordination mentions cannot leak through home alerts or badge projection", () => { + const makeItem = (id, audience) => ({ + ...event([ + ["audience", audience], + ["p", "human"], + ]), + id, + createdAt: 100, + channelId: "ch", + channelName: "test", + channelType: "stream", + category: "mention", + }); + const feed = { + feed: { + mentions: [makeItem("coord", "agents"), makeItem("answer", "everyone")], + needsAction: [], + activity: [], + agentActivity: [], + }, + }; + assert.deepEqual( + eligibleFeedNotificationItems(feed, { + mentions: true, + needsAction: true, + }).map((item) => item.id), + ["answer"], + ); + assert.equal(buildInboxItems({ feed }).length, 1); + assert.deepEqual( + buildHomeBadgeFeedItems(feed, [], new Set()).map((item) => item.id), + ["answer"], + ); +}); diff --git a/desktop/src/features/messages/lib/messageAudience.ts b/desktop/src/features/messages/lib/messageAudience.ts new file mode 100644 index 0000000000..0b7730b500 --- /dev/null +++ b/desktop/src/features/messages/lib/messageAudience.ts @@ -0,0 +1,22 @@ +/** Presentation intent only: channel ACLs, mentions and thread routing are unchanged. */ +export type MessageAudience = "agents" | "everyone"; + +/** Unknown, missing or ambiguous declarations retain legacy visibility. */ +export function messageAudience( + tags: readonly string[][] = [], +): MessageAudience | null { + const declarations = tags.filter((tag) => tag[0] === "audience"); + if (declarations.length !== 1 || declarations[0].length !== 2) return null; + const value = declarations[0][1]; + return value === "agents" || value === "everyone" ? value : null; +} + +export function isAgentCoordination(message: { + kind?: number; + tags?: string[][]; +}): boolean { + return ( + (message.kind === 9 || message.kind === 45001 || message.kind === 45003) && + messageAudience(message.tags) === "agents" + ); +} diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index 196aabbec4..36abe692f3 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -4,6 +4,7 @@ import { dimensionsFromDim } from "@/shared/ui/markdown/utils"; import type { TimelineItem } from "./timelineItems"; import type { TimelineMessage } from "../types"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; +import { isAgentCoordination } from "./messageAudience"; /** * Estimate a timeline row's rendered height so its `content-visibility` @@ -112,6 +113,8 @@ export function estimateRowHeight( message: TimelineMessage, { isContinuation = false }: { isContinuation?: boolean } = {}, ): number { + // Coordination rows render collapsed to a single short line by default. + if (isAgentCoordination(message)) return CONTINUATION_MIN_ESTIMATE; const body = message.body ?? ""; const { prose, codeLines } = splitFencedCode(body); const proseForLineCount = stripMediaOnlyLines(prose); diff --git a/desktop/src/features/messages/lib/unreadMarker.test.mjs b/desktop/src/features/messages/lib/unreadMarker.test.mjs index 6c5bd046b5..4abc08a39f 100644 --- a/desktop/src/features/messages/lib/unreadMarker.test.mjs +++ b/desktop/src/features/messages/lib/unreadMarker.test.mjs @@ -296,3 +296,34 @@ test("computeThreadUnreadMarker_selfAuthoredMixedCase_skipsOwnReplies", () => { assert.equal(marker.firstUnreadReplyId, "r2"); assert.equal(marker.unreadCount, 1); }); + +test("audience excludes automatic channel and thread unread, preserving manual intent", () => { + const messages = [ + { ...topLevel("coord", 10), kind: 9, tags: [["audience", "agents"]] }, + { ...topLevel("answer", 20), kind: 9, tags: [["audience", "everyone"]] }, + { ...topLevel("legacy", 30), kind: 9, tags: [] }, + ]; + assert.deepEqual(computeChannelUnreadMarker(messages, null), { + firstUnreadMessageId: "answer", + unreadCount: 2, + }); + assert.deepEqual( + computeThreadUnreadMarker(messages, () => null), + { + firstUnreadReplyId: "answer", + unreadCount: 2, + }, + ); + assert.deepEqual( + computeThreadUnreadMarker( + messages, + () => 100, + undefined, + (id) => id === "coord", + ), + { + firstUnreadReplyId: "coord", + unreadCount: 1, + }, + ); +}); diff --git a/desktop/src/features/messages/lib/unreadMarker.ts b/desktop/src/features/messages/lib/unreadMarker.ts index 6e47f119c5..e3ad755aa7 100644 --- a/desktop/src/features/messages/lib/unreadMarker.ts +++ b/desktop/src/features/messages/lib/unreadMarker.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "./messageAudience"; import type { TimelineMessage } from "@/features/messages/types"; /** @@ -51,7 +52,7 @@ export function computeChannelUnreadMarker( let unreadCount = 0; for (const message of messages) { - if (message.parentId) { + if (message.parentId || isAgentCoordination(message)) { continue; } if ( @@ -111,7 +112,8 @@ const EMPTY_THREAD_MARKER: ThreadUnreadMarker = { * overlay, never in the read-line. Defaults to never-forced. */ export function computeThreadUnreadMarker( - replies: Pick[], + replies: (Pick & + Partial>)[], getReadAt: (messageId: string) => number | null, currentPubkey?: string, isForcedUnread: (messageId: string) => boolean = () => false, @@ -126,6 +128,7 @@ export function computeThreadUnreadMarker( if (normalizedPubkey && reply.pubkey?.toLowerCase() === normalizedPubkey) { continue; } + if (isAgentCoordination(reply) && !isForcedUnread(reply.id)) continue; const readAt = getReadAt(reply.id); const isUnread = isForcedUnread(reply.id) || readAt === null || reply.createdAt > readAt; diff --git a/desktop/src/features/messages/ui/AgentCoordinationDisclosure.test.mjs b/desktop/src/features/messages/ui/AgentCoordinationDisclosure.test.mjs new file mode 100644 index 0000000000..4d3dadf0b2 --- /dev/null +++ b/desktop/src/features/messages/ui/AgentCoordinationDisclosure.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }), +); +afterEach(async () => (await import("@testing-library/react")).cleanup()); +after(() => dom.window.close()); + +test("collapsed coordination row is one actionable button with a plain preview", async () => { + const { createElement } = await import("react"); + const { render, fireEvent } = await import("@testing-library/react"); + const { AgentCoordinationCollapsedRow } = await import( + "./AgentCoordinationDisclosure.tsx" + ); + let expanded = 0; + const view = render( + createElement(AgentCoordinationCollapsedRow, { + author: "Fizz", + body: "**Done**: see `crates/x`\n\n```rs\nfn a() {}\n```", + createdAt: 1_700_000_000, + isAgent: true, + messageId: "m1", + onExpand: () => { + expanded += 1; + }, + }), + ); + const button = view.getByRole("button", { + name: "Agent coordination from Fizz. Show message", + }); + assert.equal(button.getAttribute("aria-expanded"), "false"); + assert.match(button.textContent ?? "", /Done: see crates\/x \[code\]/); + fireEvent.click(button); + assert.equal(expanded, 1); +}); + +test("coordinationPreview flattens markdown to one line", async () => { + const { coordinationPreview } = await import( + "./AgentCoordinationDisclosure.tsx" + ); + assert.equal( + coordinationPreview("# Title\n\n- [link](https://x.y) and _em_"), + "Title - link and em", + ); +}); diff --git a/desktop/src/features/messages/ui/AgentCoordinationDisclosure.tsx b/desktop/src/features/messages/ui/AgentCoordinationDisclosure.tsx new file mode 100644 index 0000000000..9eae769a05 --- /dev/null +++ b/desktop/src/features/messages/ui/AgentCoordinationDisclosure.tsx @@ -0,0 +1,100 @@ +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { MessageTimestamp } from "./MessageTimestamp"; + +/** Single-line plain-text preview; markdown punctuation is noise at this size. */ +export function coordinationPreview(body: string): string { + return body + .replace(/```[\s\S]*?```/g, " [code] ") + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[`*_>#~|]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Collapsed placeholder for an `audience=agents` message: one short line that + * keeps the author's avatar visible. Clicking it expands the full message row. + */ +export function AgentCoordinationCollapsedRow({ + author, + avatarUrl, + accent, + body, + createdAt, + isAgent, + messageId, + onExpand, +}: { + author: string; + avatarUrl?: string | null; + accent?: boolean; + body: string; + createdAt: number; + isAgent: boolean; + messageId: string; + onExpand: () => void; +}) { + return ( + + ); +} + +/** Header control on an expanded coordination row to fold it back down. */ +export function AgentCoordinationCollapseButton({ + onCollapse, +}: { + onCollapse: () => void; +}) { + return ( + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 75b7d0d339..7951652607 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,3 +1,8 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; +import { + AgentCoordinationCollapseButton, + AgentCoordinationCollapsedRow, +} from "./AgentCoordinationDisclosure"; import * as React from "react"; import { AlertTriangle } from "lucide-react"; import { @@ -164,7 +169,16 @@ export const MessageRow = React.memo( }) { // Keep the transient send state with its timestamp rather than collapsing // it into a grouped message row with no header. - const isDisplayedAsContinuation = isContinuation && !message.pending; + const isCoordination = isAgentCoordination(message); + const [coordinationExpanded, setCoordinationExpanded] = + React.useState(false); + const coordinationForcedOpen = highlighted || Boolean(searchQuery); + const coordinationCollapsed = + isCoordination && !coordinationExpanded && !coordinationForcedOpen; + // Collapsed coordination rows carry their own avatar, so an expanded one + // must too; never render it as a headerless continuation. + const isDisplayedAsContinuation = + isContinuation && !message.pending && !isCoordination; const [expandedDiffId, setExpandedDiffId] = React.useState( null, ); @@ -675,6 +689,15 @@ export const MessageRow = React.memo( { key: "owner", node: agentOwnerNode }, { key: "timestamp", node: inlineMetadataNode }, { key: "persona", node: personaNode }, + { + key: "coordination", + node: + isCoordination && !coordinationForcedOpen ? ( + setCoordinationExpanded(false)} + /> + ) : null, + }, ]} /> @@ -728,6 +751,30 @@ export const MessageRow = React.memo( ); + if (coordinationCollapsed) { + return ( +
0 + ? { paddingLeft: threadReplyLength(indentRem) } + : undefined + } + > + setCoordinationExpanded(true)} + /> +
+ ); + } + return (
!isAgentCoordination(item), + ); } export function eligibleFeedNotificationItems( @@ -83,5 +86,7 @@ export function eligibleFeedNotificationItems( ); } - return items.sort((left, right) => left.createdAt - right.createdAt); + return items + .filter((item) => !isAgentCoordination(item)) + .sort((left, right) => left.createdAt - right.createdAt); } diff --git a/desktop/src/features/notifications/lib/homeBadge.ts b/desktop/src/features/notifications/lib/homeBadge.ts index b98db88e0b..7c63edc7e7 100644 --- a/desktop/src/features/notifications/lib/homeBadge.ts +++ b/desktop/src/features/notifications/lib/homeBadge.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { maxReadAt } from "@/features/channels/readState/readStateFormat"; import { @@ -47,7 +48,9 @@ export function buildHomeBadgeFeedItems( ); } - return dedupeFeedItemsById(items); + return dedupeFeedItemsById( + items.filter((item) => !isAgentCoordination(item)), + ); } export function shouldCountTowardHomeBadgeSubtotal( diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 9ceb9c2df8..58862a657e 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -1,3 +1,4 @@ +import { isAgentCoordination } from "@/features/messages/lib/messageAudience"; import type { RelayEvent } from "@/shared/api/types"; import { getThreadReference, @@ -8,6 +9,7 @@ export function hasMentionForEvent( event: RelayEvent, currentPubkey: string, ): boolean { + if (isAgentCoordination(event)) return false; return ( currentPubkey.length > 0 && event.tags.some( @@ -30,6 +32,7 @@ export function shouldNotifyForEvent( currentPubkey: string, options: NotifyOptions, ): boolean { + if (isAgentCoordination(event)) return false; const { participatedRootIds, followedRootIds, @@ -79,6 +82,7 @@ export function isHighPriorityEventForUser( event: RelayEvent, currentPubkey: string, ): boolean { + if (isAgentCoordination(event)) return false; if ( currentPubkey.length > 0 && event.tags.some( diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 5304f3bf7c..5d2b9bcb65 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -993,3 +993,75 @@ test("remote read-state rollback is ignored while local mark-unread still increm await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); }); + +test("explicit agent audience collapses coordination without badge noise", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await waitForMockLiveSubscription(page, "engineering", 9); + await page.getByTestId("channel-general").click(); + const baseline = await getSettledBadgeState(page); + const coord = await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "engineering", + content: "POC coordination: checking the patch", + kind: 9, + pubkey, + mentionPubkeys: ["deadbeef".repeat(8)], + extraTags: [ + ["audience", "agents"], + ["broadcast", "1"], + ], + }), + TEST_IDENTITIES.alice.pubkey, + ); + await page.waitForTimeout(1500); + expect(await getBadgeState(page)).toEqual(baseline); + await page.getByTestId("channel-engineering").click(); + const collapsed = page.getByTestId("agent-coordination-collapsed"); + await expect(collapsed).toHaveCount(1); + await expect(collapsed).toHaveAttribute("data-message-id", coord?.id ?? ""); + await expect(collapsed).toContainText("POC coordination: checking the patch"); + // Collapsed placeholder is a single short line, not a full message row. + const collapsedBox = await collapsed.boundingBox(); + expect(collapsedBox?.height ?? 0).toBeLessThan(32); + // The placeholder keeps the author's avatar visible. + await expect(collapsed.locator("[data-avatar-shape]")).toBeVisible(); + await expect(collapsed.locator("[data-avatar-shape]")).toContainText(/\S/); + await waitForAnimations(page); + await page.screenshot({ path: "test-results/audience-collapsed.png" }); + await collapsed.click(); + await expect(collapsed).toHaveCount(0); + const collapse = page.getByTestId("agent-coordination-collapse"); + await expect(collapse).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: "test-results/audience-expanded.png" }); + await collapse.click(); + await expect(page.getByTestId("agent-coordination-collapsed")).toHaveCount(1); + await page.getByTestId("channel-general").click(); + const beforeAnswer = await getSettledBadgeState(page); + await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "engineering", + content: "POC answer: ready for your review", + kind: 9, + pubkey, + mentionPubkeys: ["deadbeef".repeat(8)], + extraTags: [["audience", "everyone"]], + }), + TEST_IDENTITIES.alice.pubkey, + ); + await expect + .poll(async () => (await getBadgeState(page)).count) + .toBeGreaterThan(beforeAnswer.count); + await page.getByTestId("channel-engineering").click(); + await expect( + page + .getByText("POC answer: ready for your review", { exact: true }) + .first(), + ).toBeVisible(); + await page.screenshot({ path: "test-results/audience-poc.png" }); +}); diff --git a/scripts/e2e-large-channel-roster.sh b/scripts/e2e-large-channel-roster.sh index 9f82f9bd35..adce3b87bf 100755 --- a/scripts/e2e-large-channel-roster.sh +++ b/scripts/e2e-large-channel-roster.sh @@ -76,7 +76,7 @@ printf 'PASS discovery-before-republish channel=%s members=%s late_pubkey=%s\n' "$CHANNEL" "$BEFORE_COUNT" "$LATE_PUBKEY" export BUZZ_PRIVATE_KEY="$LATE_SK" -ACTION="$(buzz messages send --channel "$CHANNEL" --content "member-1501-action")" +ACTION="$(buzz messages send --audience everyone --channel "$CHANNEL" --content "member-1501-action")" jq -e '.accepted == true' <<<"$ACTION" >/dev/null ACTION_ID="$(jq -er '.event_id' <<<"$ACTION")" buzz messages get --channel "$CHANNEL" --limit 10 \