Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 36 additions & 44 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>,
Expand Down Expand Up @@ -1459,8 +1453,8 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str

// 2. Context hints (with a human-aware reply anchor).
//
// Human-facing turns are anchored so replies stay readable at layer 1:
// - in a thread → anchor to the thread ROOT (no depth-2 nesting)
// Human-facing turns point at the exact message being answered:
// - in a thread → anchor to the triggering event
// - top-level → anchor to the triggering event (it becomes the root)
// Agent↔agent turns get no forced anchor — deep nesting is intentional
// there. DMs are always 1:1 with a human, so they always anchor.
Expand Down Expand Up @@ -2082,7 +2076,7 @@ mod tests {
"reply".into(),
]],
);
let _steering_id = steering.id.to_hex();
let steering_id = steering.id.to_hex();

let batch = FlushBatch {
channel_id: ch,
Expand All @@ -2101,13 +2095,12 @@ mod tests {

let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");

// Reply instruction points at the thread root of the steering message
// (thread_b), not the steering event's own id — this matches the
// human-aware reply anchoring from PR #1281: for human-facing turns in
// a thread, the anchor is always the thread root.
// Reply instruction points at the exact steering message.
// The anchor is the steering event's own id, which is the exact message
// being answered by the agent.
assert!(
prompt.contains(&format!("--reply-to {thread_b}")),
"reply instruction should target the steering thread root: {prompt}"
prompt.contains(&format!("--reply-to {steering_id}")),
"reply instruction should target the steering message: {prompt}"
);
assert!(
!prompt.contains(&format!("--reply-to {thread_a}")),
Expand Down Expand Up @@ -3275,11 +3268,11 @@ mod tests {
}

#[test]
fn test_anchor_human_in_thread_uses_root() {
// Human asks inside a thread → anchor to the thread ROOT (flat at L1).
fn test_anchor_human_in_thread_uses_triggering_event() {
// Human asks inside a thread → anchor to the triggering event.
let tags = thread_tags(Some(ROOT_ID), &[AGENT_A_PK]);
let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
assert_eq!(anchor.as_deref(), Some(ROOT_ID));
assert_eq!(anchor.as_deref(), Some(TRIGGER_ID));
}

#[test]
Expand All @@ -3306,19 +3299,19 @@ mod tests {
}

#[test]
fn test_anchor_agent_sender_but_human_tagged_flattens() {
// Agent-authored, but a human is tagged → human-facing → anchor to root.
fn test_anchor_agent_sender_but_human_tagged_marks_triggering_message() {
// Agent-authored, but a human is tagged → human-facing → mark the triggering message.
let tags = thread_tags(Some(ROOT_ID), &[AGENT_B_PK, HUMAN_PK]);
let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
assert_eq!(anchor.as_deref(), Some(ROOT_ID));
assert_eq!(anchor.as_deref(), Some(TRIGGER_ID));
}

#[test]
fn test_anchor_unknown_identity_treated_as_human() {
// No profile lookup → fail open (treat as human so visibility is kept).
let tags = thread_tags(Some(ROOT_ID), &[]);
let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None);
assert_eq!(anchor.as_deref(), Some(ROOT_ID));
assert_eq!(anchor.as_deref(), Some(TRIGGER_ID));
}

#[test]
Expand Down Expand Up @@ -3882,12 +3875,11 @@ mod tests {
};

// No profile lookup → sender treated as human → human-facing thread
// reply anchors to the thread ROOT (flat at layer 1), not the
// triggering event id.
// reply anchors to the exact triggering message.
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {root_id}")),
"human-facing thread reply should anchor to the thread root"
prompt.contains(&format!("--reply-to {}", batch.events[0].event.id.to_hex())),
"human-facing thread reply should anchor to the triggering message"
);
assert!(
prompt.contains("For ordinary replies in this turn"),
Expand Down Expand Up @@ -4005,7 +3997,7 @@ mod tests {
}

#[test]
fn test_human_thread_reply_anchors_to_root_not_triggering_or_parent() {
fn test_human_thread_reply_anchors_to_triggering_message() {
let ch = Uuid::new_v4();
let root_id = "a".repeat(64);
let parent_id = "b".repeat(64);
Expand All @@ -4028,16 +4020,16 @@ mod tests {
cancel_reason: None,
};

// Human-facing (no lookup) deep reply: anchor to the thread ROOT to
// keep the conversation flat — NOT the triggering event or parent.
// Human-facing deep replies should point at the exact message that
// triggered the turn, so the agent response visibly marks it.
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {root_id}")),
"human-facing nested reply should anchor to the thread root"
prompt.contains(&format!("--reply-to {event_id}")),
"human-facing nested reply should anchor to the triggering message"
);
assert!(
!prompt.contains(&format!("--reply-to {event_id}")),
"instruction should NOT anchor to the triggering event id"
!prompt.contains(&format!("--reply-to {root_id}")),
"instruction should NOT anchor to the thread root"
);
assert!(
!prompt.contains(&format!("--reply-to {parent_id}")),
Expand Down Expand Up @@ -4066,8 +4058,8 @@ mod tests {

let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {root_id}")),
"human-facing thread reply should anchor to the thread root"
prompt.contains(&format!("--reply-to {}", batch.events[0].event.id.to_hex())),
"human-facing thread reply should anchor to the triggering message"
);
assert!(
prompt.contains("channel-root, top-level"),
Expand Down Expand Up @@ -4107,11 +4099,11 @@ mod tests {
};

// Scope derives from the last (threaded) event; human-facing → anchor
// to that thread's root.
// to that exact event.
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {root_id}")),
"batched prompt should anchor to the last (threaded) event's root"
prompt.contains(&format!("--reply-to {}", batch.events[1].event.id.to_hex())),
"batched prompt should anchor to the last (threaded) event"
);
}

Expand Down
49 changes: 36 additions & 13 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
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
Expand All @@ -57,7 +62,7 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
async fn resolve_thread_ref(
client: &BuzzClient,
parent_event_id: &str,
) -> Result<ThreadRef, CliError> {
) -> Result<ResolvedThreadRef, CliError> {
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?;
Expand All @@ -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()
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
};
Expand Down
24 changes: 20 additions & 4 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,20 +459,36 @@ export function useSendMessageMutation(
emojiTags,
mentionTags,
} = splitOutgoingTags(mediaTags);
const cachedChannelMessages = parentEventId
? queryClient.getQueryData<RelayEvent[]>(
channelMessagesKey(effectiveChannel.id),
) ?? []
: [];
const cachedThreadMessages = parentEventId
? queryClient
.getQueriesData<RelayEvent[]>({
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<RelayEvent[]>(
channelMessagesKey(effectiveChannel.id),
) ?? [];
const result = await sendChannelMessage(
effectiveChannel.id,
content,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function messageMentionPubkeys(
channel: Channel,
senderPubkey: string,
explicitMentions: readonly string[] = [],
replyToAuthorPubkey?: string,
): string[] {
const candidates =
channel.channelType === "dm"
Expand All @@ -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(
Expand Down