diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..55f1228a72 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -53,7 +53,7 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat Use the reply destination supplied in the `[Context]` block for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root. -For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread. +For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or no reply destination for a top-level channel message so the response stays in the channel. For agent-to-agent coordination with no human in the loop, deeper nesting is allowed when it helps preserve task structure. Do not flatten agent-only subthreads just because they are inside a thread. diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..45a92630d4 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1156,19 +1156,18 @@ fn append_reply_instruction(s: &mut String, event_id: &str) { )); } -/// Append a new-thread reply instruction for a human-facing top-level mention. +/// Append a channel-root reply instruction for a top-level message. /// -/// The triggering mention has no thread tags, so the agent's reply becomes the -/// thread root. Anchoring to the triggering event (rather than leaving the -/// choice open) prevents replying into a stale/unrelated prior thread. -fn append_new_thread_reply_instruction(s: &mut String, event_id: &str) { - s.push_str(&format!( - "\nIMPORTANT: This is a new top-level message. For ordinary replies in \ - this turn, use `--reply-to {event_id}` on `buzz messages send` — the \ - triggering message is the thread root. Do NOT reply into any other \ - (older) thread. If the human explicitly asks for a channel-root, \ - top-level, or broadcast post, send that message without `--reply-to`." - )); +/// The triggering message has no thread tags, so ordinary agent replies should +/// remain in the channel timeline instead of opening a new thread. +fn append_channel_root_reply_instruction(s: &mut String) { + s.push_str( + "\nIMPORTANT: This is a top-level channel message. For ordinary replies \ + in this turn, send with `buzz messages send` without `--reply-to` so \ + the response stays in the channel. Do NOT reply into any older thread. \ + If the human explicitly asks for a threaded reply and the destination \ + is ambiguous, ask before sending.", + ); } /// Decide whether a turn is human-facing for reply-anchor purposes. @@ -1200,36 +1199,29 @@ 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 -/// - top-level → the triggering event id, which becomes the new thread root +/// Returns the thread root only for human-facing turns that are already in a +/// thread (see [`turn_is_human_facing`]). Top-level turns return `None` so the +/// agent replies at the channel root. /// /// Returns `None` for agent↔agent turns, leaving the agent free to nest deeply /// (intentional for agent coordination). fn resolve_reply_anchor( sender_pubkey: &str, thread_tags: &ThreadTags, - triggering_event_id: &str, profile_lookup: Option<&PromptProfileLookup>, ) -> Option { 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()), - ) + thread_tags.root_event_id.clone() } /// 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. +/// replies. The channel branch always instructs the agent to reply without an +/// anchor so the response remains top-level. fn format_context_hints( channel_id: Uuid, channel_info: Option<&PromptChannelInfo>, @@ -1306,9 +1298,7 @@ fn format_context_hints( Channel: {channel_display}\n\ Hint: Use `buzz messages get --channel ` for recent messages if needed." ); - if let Some(event_id) = reply_anchor { - append_new_thread_reply_instruction(&mut s, event_id); - } + append_channel_root_reply_instruction(&mut s); s } } @@ -1459,9 +1449,9 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec PromptProfile { PromptProfile { @@ -3278,30 +3267,30 @@ mod tests { fn test_anchor_human_in_thread_uses_root() { // Human asks inside a thread → anchor to the thread ROOT (flat at L1). let tags = thread_tags(Some(ROOT_ID), &[AGENT_A_PK]); - let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, Some(&id_lookup())); + let anchor = resolve_reply_anchor(HUMAN_PK, &tags, Some(&id_lookup())); assert_eq!(anchor.as_deref(), Some(ROOT_ID)); } #[test] - fn test_anchor_human_top_level_uses_triggering_event() { - // Human top-level mention (no thread tags) → triggering event is root. + fn test_anchor_human_top_level_is_none() { + // Human top-level mention (no thread tags) → reply in the channel. let tags = thread_tags(None, &[AGENT_A_PK]); - let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, Some(&id_lookup())); - assert_eq!(anchor.as_deref(), Some(TRIGGER_ID)); + let anchor = resolve_reply_anchor(HUMAN_PK, &tags, Some(&id_lookup())); + assert_eq!(anchor, None); } #[test] fn test_anchor_agent_to_agent_in_thread_is_none() { // Agent pings agent inside a thread → no forced anchor (deep nesting ok). let tags = thread_tags(Some(ROOT_ID), &[AGENT_B_PK]); - let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup())); + let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, Some(&id_lookup())); assert_eq!(anchor, None); } #[test] fn test_anchor_agent_to_agent_top_level_is_none() { let tags = thread_tags(None, &[AGENT_B_PK]); - let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup())); + let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, Some(&id_lookup())); assert_eq!(anchor, None); } @@ -3309,7 +3298,7 @@ mod tests { fn test_anchor_agent_sender_but_human_tagged_flattens() { // Agent-authored, but a human is tagged → human-facing → anchor to root. 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())); + let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, Some(&id_lookup())); assert_eq!(anchor.as_deref(), Some(ROOT_ID)); } @@ -3317,7 +3306,7 @@ mod tests { 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); + let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, None); assert_eq!(anchor.as_deref(), Some(ROOT_ID)); } @@ -3326,7 +3315,7 @@ mod tests { // Raw p-tag presence must NOT flatten when every tagged pubkey is an // agent — this is the regression Pinky flagged. let tags = thread_tags(Some(ROOT_ID), &[AGENT_A_PK, AGENT_B_PK]); - let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup())); + let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, Some(&id_lookup())); assert_eq!(anchor, None); } @@ -3957,17 +3946,20 @@ mod tests { cancel_reason: None, }; - // Top-level human message (no lookup → human): the reply opens a new - // thread anchored to the triggering event, preventing replies into a - // stale older thread. + // Top-level human message: the reply stays in the channel and must not + // reuse either the triggering event or a stale older thread. let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!( - prompt.contains(&format!("--reply-to {event_id}")), - "top-level human message should anchor a new thread at the triggering event" + !prompt.contains(&format!("--reply-to {event_id}")), + "top-level human message should not open a thread at the triggering event" + ); + assert!( + prompt.contains("send with `buzz messages send` without `--reply-to`"), + "top-level human message should use the channel-root instruction" ); assert!( - prompt.contains("new top-level message"), - "top-level human message should use the new-thread instruction" + prompt.contains("Do NOT reply into any older thread"), + "top-level human message should forbid stale thread reuse" ); } @@ -4121,7 +4113,7 @@ mod tests { let root_id = "d".repeat(64); let threaded = make_event_with_tags( "earlier thread msg", - vec![vec!["e".into(), root_id, "".into(), "reply".into()]], + vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let plain = make_event("latest top-level"); let plain_id = plain.id.to_hex(); @@ -4143,16 +4135,20 @@ mod tests { cancel_reason: None, }; - // Last event is top-level and human-facing → opens a new thread - // anchored to that top-level event (NOT the earlier thread's root). + // Last event is top-level → reply in the channel, not under either the + // top-level event or the earlier thread. let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!( - prompt.contains(&format!("--reply-to {plain_id}")), - "batched top-level-last prompt should anchor to the last (top-level) event" + !prompt.contains(&format!("--reply-to {plain_id}")), + "batched top-level-last prompt should not open a thread" + ); + assert!( + !prompt.contains(&format!("--reply-to {root_id}")), + "batched top-level-last prompt should not reuse the earlier thread" ); assert!( - prompt.contains("new top-level message"), - "batched top-level-last prompt should use the new-thread instruction" + prompt.contains("send with `buzz messages send` without `--reply-to`"), + "batched top-level-last prompt should use the channel-root instruction" ); } diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..599132d822 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -590,8 +590,8 @@ async fn handle_setup_membership( /// Build and publish a setup nudge reply to the triggering event. /// -/// Threading: flat reply to the thread root if one exists; otherwise reply -/// to the triggering event itself. P-tags the asker. +/// Threading: flat reply to the thread root if one exists; otherwise post at +/// the channel root. P-tags the asker. async fn publish_setup_nudge( publisher: &RelayEventPublisher, keys: &nostr::Keys, @@ -613,11 +613,8 @@ async fn publish_setup_nudge( parent_event_id: root_id, }) } else { - // Top-level event: reply to the triggering event. - Some(ThreadRef { - root_event_id: triggering_event.id, - parent_event_id: triggering_event.id, - }) + // Top-level event: keep the nudge in the channel timeline. + None }; let body = payload.nudge_body();