feat: tag agent messages with an explicit audience

buzz messages send now requires --audience agents|everyone and signs it
into the event. Desktop collapses audience=agents messages to a single
short row (avatar, name, preview, time) that expands on click, and
leaves them out of unread counts, notifications, and the inbox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-09-24 14:04:29 -07:00
parent 28a8b90805
commit 19da895012
33 changed files with 732 additions and 28 deletions

View File

@@ -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 <channel-id> --content <text>`. Your team, your
`buzz messages send --audience agents --channel <channel-id> --content <text>`. 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.

View File

@@ -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 <channel-id> --content <text>`. Your turn is
`buzz messages send --audience agents --channel <channel-id> --content <text>`. Your turn is
not complete until you have published your message. Do not use the shell
for task work — that is your workers' job.

View File

@@ -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 <channel-id> --content <text>`. Your team and your
`buzz messages send --audience agents --channel <channel-id> --content <text>`. 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.

View File

@@ -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 <channel-id> --content <text>`. Your turn is
`buzz messages send --audience agents --channel <channel-id> --content <text>`. Your turn is
not complete until you have published your report.
The orchestrator only wakes for messages that @mention it. Every report

View File

@@ -833,6 +833,8 @@ class BuzzContainerRuntime:
args = [
"messages",
"send",
"--audience",
"everyone",
"--channel",
trial.channel_id,
"--content",

View File

@@ -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 <channel-id> --reply-to <thread-root-id> \
buzz messages send --audience everyone --channel <channel-id> --reply-to <thread-root-id> \
--mention <agent-pubkey> --content '!cancel'
```

View File

@@ -19,6 +19,14 @@ Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message
When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `<context>`. 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.

View File

@@ -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 <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages send --audience everyone --channel <uuid> --content "Hello"
buzz messages send --audience everyone --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --audience everyone --channel <uuid> --content - < message.md # read body from stdin
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages thread --link 'buzz://message?channel=<uuid>&id=<event-id>&thread=<root-id>'

View File

@@ -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')

View File

@@ -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<u16>,
@@ -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.

View File

@@ -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 <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
after_help = "Examples:\n buzz messages send --audience everyone --channel <UUID> --content \"hello\"\n buzz messages send --audience everyone --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --audience everyone --channel <UUID> --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<String>,
/// 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() {

View File

@@ -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<nostr::EventBuilder, crate::SdkError> {
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<Self, Self::Err> {
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<String>]) -> 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::<MessageAudience>().is_err());
}
}

View File

@@ -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;

View File

@@ -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 <hex-or-npub>`. 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 <UUID> \
buzz messages send --audience everyone --channel <UUID> \
--content "@Alice check this" --mention <alice-pubkey>
```

View File

@@ -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<String>,
authored: &HashSet<String>,
) -> 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::<Vec<_>>(),
["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();

View File

@@ -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 <channel-id> --reply-to <thread-root-id> \
buzz messages send --audience everyone --channel <channel-id> --reply-to <thread-root-id> \
--mention <agent-pubkey> --content '!cancel'
```

View File

@@ -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;
})

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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: [],

View File

@@ -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"],
);
});

View File

@@ -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"
);
}

View File

@@ -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);

View File

@@ -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,
},
);
});

View File

@@ -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<TimelineMessage, "id" | "createdAt" | "pubkey">[],
replies: (Pick<TimelineMessage, "id" | "createdAt" | "pubkey"> &
Partial<Pick<TimelineMessage, "kind" | "tags">>)[],
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;

View File

@@ -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("<!doctype html><html><body></body></html>", {
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",
);
});

View File

@@ -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 (
<button
type="button"
aria-expanded={false}
aria-label={`Agent coordination from ${author}. Show message`}
className={cn(
"group/coordination mx-1 flex w-[calc(100%-0.5rem)] min-w-0 items-center gap-2 rounded-md px-2 py-0.5 text-left",
"text-xs text-muted-foreground/70 hover:bg-muted/50 hover:text-muted-foreground",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
)}
data-message-id={messageId}
data-testid="agent-coordination-collapsed"
onClick={onExpand}
>
{/* Match the 36px avatar gutter so collapsed rows align with full rows. */}
<span className="flex w-9 shrink-0 justify-center">
<UserAvatar
accent={accent}
avatarUrl={avatarUrl ?? null}
displayName={author}
shape={isAgent ? "squircle" : "circle"}
size="xs"
/>
</span>
<span className="shrink-0 font-medium text-muted-foreground">
{author}
</span>
<ChevronRight
aria-hidden="true"
className="h-3 w-3 shrink-0 opacity-60 transition-transform group-hover/coordination:translate-x-0.5"
/>
<span className="min-w-0 flex-1 truncate">
{coordinationPreview(body)}
</span>
<MessageTimestamp
className="text-muted-foreground/45"
createdAt={createdAt}
hideDayPeriod
/>
</button>
);
}
/** Header control on an expanded coordination row to fold it back down. */
export function AgentCoordinationCollapseButton({
onCollapse,
}: {
onCollapse: () => void;
}) {
return (
<button
type="button"
aria-expanded={true}
className="rounded px-1 text-xs text-muted-foreground/70 hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="agent-coordination-collapse"
onClick={onCollapse}
>
Agent coordination · Collapse
</button>
);
}

View File

@@ -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<string | null>(
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 ? (
<AgentCoordinationCollapseButton
onCollapse={() => setCoordinationExpanded(false)}
/>
) : null,
},
]}
/>
</MessageHeaderRow>
@@ -728,6 +751,30 @@ export const MessageRow = React.memo(
</>
);
if (coordinationCollapsed) {
return (
<div
className="relative"
style={
indentRem > 0
? { paddingLeft: threadReplyLength(indentRem) }
: undefined
}
>
<AgentCoordinationCollapsedRow
accent={message.accent}
author={message.author}
avatarUrl={message.avatarUrl}
body={message.body}
createdAt={message.createdAt}
isAgent={isAuthorAgent}
messageId={message.id}
onExpand={() => setCoordinationExpanded(true)}
/>
</div>
);
}
return (
<div
className="relative"

View File

@@ -1,3 +1,4 @@
import { isAgentCoordination } from "@/features/messages/lib/messageAudience";
import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat";
@@ -53,7 +54,9 @@ export function notificationBody(item: FeedItem) {
}
export function collectHomeAlertItems(feed: HomeFeedResponse) {
return [...feed.feed.mentions, ...feed.feed.needsAction];
return [...feed.feed.mentions, ...feed.feed.needsAction].filter(
(item) => !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);
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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" });
});

View File

@@ -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 \