From 4fc78847b1ab39fef77cf18593ac9ff26fc81db9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:58:13 +0100 Subject: [PATCH] Route automation marker tags through the client tag channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/commands/message_converters.rs | 96 ++++++++++++++++ desktop/src-tauri/src/commands/messages.rs | 104 ++---------------- desktop/src-tauri/src/commands/mod.rs | 1 + .../features/chats/lib/chatWorkAutomation.ts | 7 +- .../chats/ui/ChatConversationRows.tsx | 4 +- desktop/src/features/messages/hooks.ts | 15 ++- .../messages/lib/imetaMediaMarkdown.test.mjs | 11 +- .../messages/lib/imetaMediaMarkdown.ts | 9 +- desktop/src/shared/api/tauri.ts | 2 + desktop/src/testing/e2eBridge.ts | 18 ++- desktop/tests/e2e/chats-first-message.spec.ts | 6 +- 11 files changed, 162 insertions(+), 111 deletions(-) create mode 100644 desktop/src-tauri/src/commands/message_converters.rs diff --git a/desktop/src-tauri/src/commands/message_converters.rs b/desktop/src-tauri/src/commands/message_converters.rs new file mode 100644 index 0000000000..6123d08832 --- /dev/null +++ b/desktop/src-tauri/src/commands/message_converters.rs @@ -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 { + 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> { + 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, + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 3a057eaa18..1765552167 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -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>>, mention_pubkeys: Option>, kind: Option, + client_tags: Option>>, state: State<'_, AppState>, ) -> Result { 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 = 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 { - 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> { - 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, - } -} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 9b6c8a9de5..05fd83959b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -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; diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index ee02f9b9f5..4470275d66 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -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[] { diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 96d6af6630..858890671b 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -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 ( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 341cdacf83..bc8e7f0e8e 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -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( 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. diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 1bbbdce9f9..78f661cec8 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -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, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 10516555d5..873cc6fe4c 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -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 }; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index f181d04fc8..016f1647de 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -837,6 +837,7 @@ export async function sendChannelMessage( kind?: number, emojiTags?: string[][], mentionTags?: string[][], + clientTags?: string[][], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -849,6 +850,7 @@ export async function sendChannelMessage( mentionTags: mentionTags ?? null, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, + clientTags: clientTags ?? null, }, ); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1b4f02d4b7..c968d9778f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7172,6 +7172,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + clientTags?: string[][] | null; }, config: E2eConfig | undefined, ): Promise { @@ -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); diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 133a39c788..116d84bf2b 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -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", ), ), ),