mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -833,6 +833,8 @@ class BuzzContainerRuntime:
|
||||
args = [
|
||||
"messages",
|
||||
"send",
|
||||
"--audience",
|
||||
"everyone",
|
||||
"--channel",
|
||||
trial.channel_id,
|
||||
"--content",
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
96
crates/buzz-sdk/src/audience.rs
Normal file
96
crates/buzz-sdk/src/audience.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
113
desktop/src/features/messages/lib/messageAudience.test.mjs
Normal file
113
desktop/src/features/messages/lib/messageAudience.test.mjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
22
desktop/src/features/messages/lib/messageAudience.ts
Normal file
22
desktop/src/features/messages/lib/messageAudience.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
100
desktop/src/features/messages/ui/AgentCoordinationDisclosure.tsx
Normal file
100
desktop/src/features/messages/ui/AgentCoordinationDisclosure.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user