From 19f57dd23e09aa24f82888b60b195561b17dd108 Mon Sep 17 00:00:00 2001 From: Rafael Oliveira Date: Sat, 1 Aug 2026 04:17:27 -0300 Subject: [PATCH] fix(agents): reply to exact triggering message Signed-off-by: Rafael Oliveira --- crates/buzz-acp/src/queue.rs | 80 +++++++++---------- crates/buzz-cli/src/commands/messages.rs | 49 +++++++++--- desktop/src/features/messages/hooks.ts | 24 +++++- .../lib/messageMentionPubkeys.test.mjs | 12 +++ .../messages/lib/messageMentionPubkeys.ts | 6 +- 5 files changed, 109 insertions(+), 62 deletions(-) diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..4db6dae1bb 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1201,7 +1201,7 @@ fn turn_is_human_facing( /// Resolve the `--reply-to` anchor for a non-DM turn. /// /// Returns `Some(id)` only for human-facing turns (see [`turn_is_human_facing`]): -/// - in a thread → the thread ROOT, keeping the reply flat at layer 1 +/// - in a thread → the triggering event id, so the reply marks the message /// - top-level → the triggering event id, which becomes the new thread root /// /// Returns `None` for agent↔agent turns, leaving the agent free to nest deeply @@ -1215,21 +1215,15 @@ fn resolve_reply_anchor( if !turn_is_human_facing(sender_pubkey, thread_tags, profile_lookup) { return None; } - Some( - thread_tags - .root_event_id - .clone() - .unwrap_or_else(|| triggering_event_id.to_string()), - ) + Some(triggering_event_id.to_string()) } /// Format a `[Context]` hints section based on event scope. /// -/// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see -/// [`resolve_reply_anchor`]). In the thread/DM branches it threads ordinary -/// replies; in the channel branch a `Some` anchor means a human-facing -/// top-level mention whose reply should open a new thread rooted at the -/// triggering event. +/// `reply_anchor` points at the exact triggering message for human-facing turns (see +/// [`resolve_reply_anchor`]). Human-facing replies mark the exact +/// triggering message; in the channel branch that message becomes the +/// new thread root. fn format_context_hints( channel_id: Uuid, channel_info: Option<&PromptChannelInfo>, @@ -1459,8 +1453,8 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec Option { root.or(reply) } +struct ResolvedThreadRef { + thread_ref: ThreadRef, + parent_author_pubkey: String, +} + /// Build a `ThreadRef` for a reply, given the immediate parent's event ID. /// /// Fetches the parent event from the relay and inspects its NIP-10 `e` tags to @@ -57,7 +62,7 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option { async fn resolve_thread_ref( client: &BuzzClient, parent_event_id: &str, -) -> Result { +) -> Result { let parent_eid = parse_event_id(parent_event_id)?; let filter = serde_json::json!({ "ids": [parent_event_id], "limit": 1 }); let raw = client.query(&filter).await?; @@ -67,6 +72,16 @@ async fn resolve_thread_ref( .as_array() .and_then(|a| a.first()) .ok_or_else(|| CliError::Other(format!("parent event {parent_event_id} not found")))?; + let parent_author_pubkey = event + .get("pubkey") + .and_then(serde_json::Value::as_str) + .and_then(|pubkey| PublicKey::from_hex(pubkey).ok()) + .map(|pubkey| pubkey.to_hex()) + .ok_or_else(|| { + CliError::Other(format!( + "parent event {parent_event_id} has an invalid pubkey" + )) + })?; let tags = event .get("tags") .cloned() @@ -77,9 +92,12 @@ async fn resolve_thread_ref( _ => parent_eid, }; - Ok(ThreadRef { - root_event_id: root_eid, - parent_event_id: parent_eid, + Ok(ResolvedThreadRef { + thread_ref: ThreadRef { + root_event_id: root_eid, + parent_event_id: parent_eid, + }, + parent_author_pubkey, }) } @@ -596,7 +614,18 @@ pub async fn cmd_send_message( let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty(); let (member_pubkeys, auto_resolved) = resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; - let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; + let mut mention_pubkeys = + merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; + let resolved_thread = if let Some(parent_event_id) = p.reply_to.as_deref() { + Some(resolve_thread_ref(client, parent_event_id).await?) + } else { + None + }; + if let Some(resolved) = &resolved_thread { + if !mention_pubkeys.contains(&resolved.parent_author_pubkey) { + mention_pubkeys.push(resolved.parent_author_pubkey.clone()); + } + } let missing = missing_members(&mention_pubkeys, &member_pubkeys); if !missing.is_empty() { @@ -633,13 +662,7 @@ pub async fn cmd_send_message( format!("{}{media_content}", p.content) }; - // Build thread ref if replying. `--reply-to` is the immediate parent; the - // thread root is derived from the parent's NIP-10 tags via the relay. - let thread_ref = if let Some(ref r) = p.reply_to { - Some(resolve_thread_ref(client, r).await?) - } else { - None - }; + let thread_ref = resolved_thread.map(|resolved| resolved.thread_ref); let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); @@ -746,7 +769,7 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re // `--reply-to` is the immediate parent; the thread root is derived from // the parent's NIP-10 tags via the relay. let thread_ref = if let Some(r) = &p.reply_to { - Some(resolve_thread_ref(client, r).await?) + Some(resolve_thread_ref(client, r).await?.thread_ref) } else { None }; diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..d4981b524b 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -459,20 +459,36 @@ export function useSendMessageMutation( emojiTags, mentionTags, } = splitOutgoingTags(mediaTags); + const cachedChannelMessages = parentEventId + ? queryClient.getQueryData( + channelMessagesKey(effectiveChannel.id), + ) ?? [] + : []; + const cachedThreadMessages = parentEventId + ? queryClient + .getQueriesData({ + queryKey: ["thread-replies", effectiveChannel.id], + }) + .flatMap(([, messages]) => messages ?? []) + : []; + const cachedMessages = [ + ...cachedChannelMessages, + ...cachedThreadMessages, + ]; + const parentAuthorPubkey = parentEventId + ? cachedMessages.find((event) => event.id === parentEventId)?.pubkey + : undefined; const recipientPubkeys = messageMentionPubkeys( effectiveChannel, identity.pubkey, mentionPubkeys, + parentAuthorPubkey, ); // 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) { - const cachedMessages = - queryClient.getQueryData( - channelMessagesKey(effectiveChannel.id), - ) ?? []; const result = await sendChannelMessage( effectiveChannel.id, content, diff --git a/desktop/src/features/messages/lib/messageMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/messageMentionPubkeys.test.mjs index 34cf76a999..9a13e969f0 100644 --- a/desktop/src/features/messages/lib/messageMentionPubkeys.test.mjs +++ b/desktop/src/features/messages/lib/messageMentionPubkeys.test.mjs @@ -46,3 +46,15 @@ test("stream messages preserve explicit-mention semantics", () => { [], ); }); + +test("stream replies address the author of the parent message", () => { + assert.deepEqual( + messageMentionPubkeys( + channel({ channelType: "stream" }), + "owner", + [], + "agent", + ), + ["agent"], + ); +}); diff --git a/desktop/src/features/messages/lib/messageMentionPubkeys.ts b/desktop/src/features/messages/lib/messageMentionPubkeys.ts index 638d8d1694..bee6170c38 100644 --- a/desktop/src/features/messages/lib/messageMentionPubkeys.ts +++ b/desktop/src/features/messages/lib/messageMentionPubkeys.ts @@ -13,6 +13,7 @@ export function messageMentionPubkeys( channel: Channel, senderPubkey: string, explicitMentions: readonly string[] = [], + replyToAuthorPubkey?: string, ): string[] { const candidates = channel.channelType === "dm" @@ -21,7 +22,10 @@ export function messageMentionPubkeys( ...channel.memberPubkeys, ...channel.participantPubkeys, ] - : explicitMentions; + : [ + ...explicitMentions, + ...(replyToAuthorPubkey ? [replyToAuthorPubkey] : []), + ]; const sender = normalizePubkey(senderPubkey); return [...new Set(candidates.map(normalizePubkey))].filter(