Route automation marker tags through the client tag channel

Every automation send was failing client-side: the marker tag rode the
outgoing media channel, whose builder rejects any non-imeta prefix —
so Run now toasted an ask (now an error, after the surfacing fix) but
no prompt ever reached the relay, the agent never started, and the
activation card sat on "Starting…" forever. The e2e mock accepted the
invalid tag, which is how the route stayed green while broken live.

The marker now rides the existing whitelisted ["client", ...] channel:
send_channel_message accepts client_tags and threads them to
build_message_with_client_tags; splitOutgoingTags gets a clientTags
bucket (client tags force the REST path so the tag-validating builder
runs); and the mock now enforces the imeta-only media gate and echoes
client tags, closing the test blind spot. The event→model converters
move to message_converters.rs to hold the size ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:52:32 +01:00
co-authored by Claude Fable 5
parent 3dcf6b0a62
commit 4fc78847b1
11 changed files with 162 additions and 111 deletions
@@ -0,0 +1,96 @@
//! Event → UI-model converters shared by the message feed commands.
use crate::models::{FeedItemInfo, ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary};
pub(super) fn channel_id_from_tags(ev: &nostr::Event) -> Option<String> {
ev.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "h" {
Some(s[1].clone())
} else {
None
}
})
}
pub(super) fn tags_to_vec(ev: &nostr::Event) -> Vec<Vec<String>> {
ev.tags.iter().map(|t| t.as_slice().to_vec()).collect()
}
pub(super) fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
let channel_id = channel_id_from_tags(ev);
FeedItemInfo {
id: ev.id.to_hex(),
kind: ev.kind.as_u16() as u32,
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
created_at: ev.created_at.as_secs(),
channel_id,
channel_name: String::new(),
channel_type: None,
tags: tags_to_vec(ev),
category: category.to_string(),
}
}
pub(super) fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo {
ForumMessageInfo {
event_id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
created_at: ev.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(ev),
thread_summary: Some(ThreadSummary {
reply_count: 0,
descendant_count: 0,
last_reply_at: None,
participants: Vec::new(),
}),
reactions: serde_json::Value::Null,
}
}
pub(super) fn forum_reply_from_event(
ev: &nostr::Event,
channel_id: &str,
root_event_id: &str,
) -> ForumThreadReplyInfo {
// Walk e-tags for NIP-10 parent/root markers.
let (mut parent_id, mut explicit_root) = (None, None);
for t in ev.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "e" {
match s.get(3).map(|x| x.as_str()) {
Some("root") => explicit_root = Some(s[1].clone()),
Some("reply") => parent_id = Some(s[1].clone()),
_ => {
if parent_id.is_none() {
parent_id = Some(s[1].clone());
}
}
}
}
}
let parent = parent_id
.clone()
.unwrap_or_else(|| root_event_id.to_string());
let root = explicit_root.unwrap_or_else(|| root_event_id.to_string());
let depth = if parent == root { 1 } else { 2 };
ForumThreadReplyInfo {
event_id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
created_at: ev.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(ev),
parent_event_id: Some(parent),
root_event_id: Some(root),
depth,
broadcast: false,
reactions: serde_json::Value::Null,
}
}
+9 -95
View File
@@ -1,6 +1,10 @@
use nostr::{Event, EventId, Keys, PublicKey};
use tauri::{AppHandle, State};
use super::message_converters::{
feed_item_from_event, forum_message_from_event, forum_reply_from_event,
};
use crate::{
app_state::AppState,
events,
@@ -8,7 +12,7 @@ use crate::{
models::{
FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse,
ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse,
ThreadRepliesResponse, ThreadSummary,
ThreadRepliesResponse,
},
nostr_convert,
relay::{query_relay, submit_event, submit_event_with_keys},
@@ -484,6 +488,7 @@ pub async fn send_channel_message(
mention_tags: Option<Vec<Vec<String>>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
client_tags: Option<Vec<Vec<String>>>,
state: State<'_, AppState>,
) -> Result<SendChannelMessageResponse, String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
@@ -493,6 +498,7 @@ pub async fn send_channel_message(
let media = media_tags.unwrap_or_default();
let emoji = emoji_tags.unwrap_or_default();
let mention_refs_only = mention_tags.unwrap_or_default();
let client = client_tags.unwrap_or_default();
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
let mut resolved_root: Option<String> = None;
@@ -529,7 +535,7 @@ pub async fn send_channel_message(
}
None => None,
};
events::build_message(
events::build_message_with_client_tags(
channel_uuid,
content.trim(),
thread_ref.as_ref(),
@@ -537,6 +543,7 @@ pub async fn send_channel_message(
&media,
&emoji,
&mention_refs_only,
&client,
)?
}
};
@@ -986,96 +993,3 @@ pub async fn delete_message(
}
// ── Local helpers ───────────────────────────────────────────────────────────
fn channel_id_from_tags(ev: &nostr::Event) -> Option<String> {
ev.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "h" {
Some(s[1].clone())
} else {
None
}
})
}
fn tags_to_vec(ev: &nostr::Event) -> Vec<Vec<String>> {
ev.tags.iter().map(|t| t.as_slice().to_vec()).collect()
}
fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
let channel_id = channel_id_from_tags(ev);
FeedItemInfo {
id: ev.id.to_hex(),
kind: ev.kind.as_u16() as u32,
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
created_at: ev.created_at.as_secs(),
channel_id,
channel_name: String::new(),
channel_type: None,
tags: tags_to_vec(ev),
category: category.to_string(),
}
}
fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo {
ForumMessageInfo {
event_id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
created_at: ev.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(ev),
thread_summary: Some(ThreadSummary {
reply_count: 0,
descendant_count: 0,
last_reply_at: None,
participants: Vec::new(),
}),
reactions: serde_json::Value::Null,
}
}
fn forum_reply_from_event(
ev: &nostr::Event,
channel_id: &str,
root_event_id: &str,
) -> ForumThreadReplyInfo {
// Walk e-tags for NIP-10 parent/root markers.
let (mut parent_id, mut explicit_root) = (None, None);
for t in ev.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "e" {
match s.get(3).map(|x| x.as_str()) {
Some("root") => explicit_root = Some(s[1].clone()),
Some("reply") => parent_id = Some(s[1].clone()),
_ => {
if parent_id.is_none() {
parent_id = Some(s[1].clone());
}
}
}
}
}
let parent = parent_id
.clone()
.unwrap_or_else(|| root_event_id.to_string());
let root = explicit_root.unwrap_or_else(|| root_event_id.to_string());
let depth = if parent == root { 1 } else { 2 };
ForumThreadReplyInfo {
event_id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
created_at: ev.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(ev),
parent_event_id: Some(parent),
root_event_id: Some(root),
depth,
broadcast: false,
reactions: serde_json::Value::Null,
}
}
+1
View File
@@ -22,6 +22,7 @@ mod media_download;
mod media_transcode;
#[cfg(feature = "mesh-llm")]
mod mesh_llm;
mod message_converters;
mod messages;
mod notifications;
mod observer_archive;
@@ -13,10 +13,9 @@ const STORAGE_EVENT = "buzz:chat-work-automation-changed";
* the chat timeline renders only its activity — not the message bubble — so
* armed automation feels ambient instead of ventriloquized.
*/
export const CHAT_AUTOMATION_TAG: [string, string] = [
"automation",
"work-panel",
];
// Rides the whitelisted ["client", ...] marker-tag channel: the imeta-only
// media path rejects any other prefix and fails the whole send.
export const CHAT_AUTOMATION_TAG: [string, string] = ["client", "automation"];
/** Tag for one automation prompt, carrying its kind for the marker row. */
export function chatAutomationTag(kind: "ci" | "comments"): string[] {
@@ -182,7 +182,9 @@ export function ChatAutomationRow({
agentName: string;
event: RelayEvent;
}) {
const tag = event.tags.find((candidate) => candidate[0] === "automation");
const tag = event.tags.find(
(candidate) => candidate[0] === "client" && candidate[1] === "automation",
);
return (
<Message side="center">
<MessageContent className="w-full max-w-full">
+11 -4
View File
@@ -506,12 +506,18 @@ export function useSendMessageMutation(
mediaTags: imetaTags,
emojiTags,
mentionTags,
clientTags,
} = splitOutgoingTags(mediaTags);
// Messages carrying media OR custom-emoji tags MUST go through REST so
// the relay's tag validation runs. The WebSocket path emits no extra
// tags, so emoji-only messages would otherwise lose their emoji tag.
if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) {
// Messages carrying media, custom-emoji, or client marker tags MUST go
// through REST so the tag-validating builder runs. The WebSocket path
// emits no extra tags, so those sends would otherwise lose their tags.
if (
parentEventId ||
imetaTags.length > 0 ||
emojiTags.length > 0 ||
clientTags.length > 0
) {
const cachedMessages =
queryClient.getQueryData<RelayEvent[]>(
channelMessagesKey(effectiveChannel.id),
@@ -525,6 +531,7 @@ export function useSendMessageMutation(
undefined,
emojiTags,
mentionTags,
clientTags,
);
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji.
@@ -531,14 +531,23 @@ const MENTION_REF = [
"1111111111111111111111111111111111111111111111111111111111111111",
];
test("splitOutgoingTags: undefined input yields three empty arrays", () => {
test("splitOutgoingTags: undefined input yields four empty arrays", () => {
assert.deepEqual(splitOutgoingTags(undefined), {
mediaTags: [],
emojiTags: [],
mentionTags: [],
clientTags: [],
});
});
test("splitOutgoingTags: routes client marker tags off the media channel", () => {
const { mediaTags, clientTags } = splitOutgoingTags([
["client", "automation", "ci"],
]);
assert.deepEqual(mediaTags, []);
assert.deepEqual(clientTags, [["client", "automation", "ci"]]);
});
test("splitOutgoingTags: separates emoji tags from imeta tags", () => {
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
IMETA,
@@ -310,18 +310,25 @@ export function splitOutgoingTags(tags: string[][] | undefined): {
mediaTags: string[][];
emojiTags: string[][];
mentionTags: string[][];
clientTags: string[][];
} {
const mediaTags: string[][] = [];
const emojiTags: string[][] = [];
const mentionTags: string[][] = [];
const clientTags: string[][] = [];
for (const tag of tags ?? []) {
if (tag[0] === "emoji") {
emojiTags.push(tag);
} else if (tag[0] === "mention") {
mentionTags.push(tag);
} else if (tag[0] === "client") {
// Whitelisted client marker tags (e.g. automation prompts) — these
// must NOT ride the imeta-only media channel, whose builder rejects
// any non-imeta prefix and fails the whole send.
clientTags.push(tag);
} else {
mediaTags.push(tag);
}
}
return { mediaTags, emojiTags, mentionTags };
return { mediaTags, emojiTags, mentionTags, clientTags };
}
+2
View File
@@ -837,6 +837,7 @@ export async function sendChannelMessage(
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
clientTags?: string[][],
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -849,6 +850,7 @@ export async function sendChannelMessage(
mentionTags: mentionTags ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
clientTags: clientTags ?? null,
},
);
+16 -2
View File
@@ -7172,6 +7172,7 @@ async function handleSendChannelMessage(
mentionPubkeys?: string[];
mediaTags?: string[][] | null;
emojiTags?: string[][] | null;
clientTags?: string[][] | null;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
@@ -7187,12 +7188,25 @@ async function handleSendChannelMessage(
// event; mirror that here so attachment renderers (FileCard, images, video)
// have the imeta tags they key on. `null`/empty → no extra tags.
const mediaTags = args.mediaTags ?? [];
// Mirror the Rust builder's imeta-only gate: a non-imeta tag on the media
// channel fails the real send, and the mock accepting it is exactly how a
// broken-live tag route stayed green in e2e.
for (const tag of mediaTags) {
if (tag[0] !== "imeta") {
throw new Error(`media tags must use 'imeta' prefix (got ${tag[0]})`);
}
}
// NIP-30 custom-emoji tags ride their own validated arg server-side; the
// relay echoes them back on the stored event too, so mirror that here so the
// emoji renderer keeps resolving `:shortcode:` after the round-trip.
const emojiTags = args.emojiTags ?? [];
// Both kinds end up on the stored event's tag set, just like the real relay.
const extraTags = [...mediaTags, ...emojiTags];
// Whitelisted ["client", ...] marker tags (automation prompts) — echoed
// back on the stored event like the real builder.
const clientTags = (args.clientTags ?? []).filter(
(tag) => tag[0] === "client",
);
// All kinds end up on the stored event's tag set, just like the real relay.
const extraTags = [...mediaTags, ...emojiTags, ...clientTags];
const identity = getIdentity(config);
if (!identity) {
const createdAt = Math.floor(Date.now() / 1000);
@@ -214,7 +214,7 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload?: { content?: string; mediaTags?: string[][] };
payload?: { content?: string; clientTags?: string[][] };
}>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []
@@ -222,8 +222,8 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
(entry) =>
entry.command === "send_channel_message" &&
entry.payload?.content?.includes("unanswered review comments") &&
entry.payload?.mediaTags?.some(
(tag) => tag[0] === "automation" && tag[1] === "work-panel",
entry.payload?.clientTags?.some(
(tag) => tag[0] === "client" && tag[1] === "automation",
),
),
),