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 000000000..6123d0883 --- /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 3a057eaa1..176555216 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 9b6c8a9de..05fd83959 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 ee02f9b9f..4470275d6 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 96d6af663..858890671 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 341cdacf8..bc8e7f0e8 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 1bbbdce9f..78f661cec 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 10516555d..873cc6fe4 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 f181d04fc..016f1647d 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 1b4f02d4b..c968d9778 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 133a39c78..116d84bf2 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", ), ), ),