From 885585188f3d46f0b2b51e9e92624bb1fb0b8580 Mon Sep 17 00:00:00 2001 From: npub1ty04d6th3jzvggd25za6xmqrh99g42kvkvqsmf7ydv793fgyxa3s3t2lw7 <591f56e9778c84c421aaa0bba36c03b94a8aaaccb3010da7c46b3c58a5043763@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 11:06:46 -0700 Subject: [PATCH] fix(buzz-acp): anchor replies for natively-steered events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a message reaches an agent mid-turn via the goose-native steer path (try_native_steer), the steer body carried only the framing header, the event block, and the closing note — no reply-anchor instruction. The agent's only in-context --reply-to guidance was the stale anchor from its original prompt, so it replied into the old thread even when the steered message was top-level or belonged to a different thread. Observed in the wild: a top-level 'give me a status report' mention was answered inside the agent's old task thread. Every other delivery path already anchors: fresh top-level turns get append_new_thread_reply_instruction, fresh thread turns get append_reply_instruction, and the cancel+merge fallback re-derives the anchor in format_prompt from the last event. Native steer was the only path with no anchor at all. Fix: native_steer_reply_instruction derives the anchor from the steered event's own thread tags — its thread root when threaded, the event itself when top-level — mirroring resolve_reply_anchor, and is appended to the steer body after the event block. No profile lookup is available on this synchronous path, so no agent-to-agent exemption is applied; that matches turn_is_human_facing's documented fail-open policy (unknown identity is treated as human). The instruction is scoped to 'replies to this new message' so in-progress work keeps its original anchor. Tests pin both derivations (top-level anchors to the event, threaded anchors to the steered event's root, never the event id). Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- crates/buzz-acp/src/lib.rs | 9 +++- crates/buzz-acp/src/queue.rs | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4a7fdc8514..3f7455714e 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2504,13 +2504,20 @@ fn try_native_steer( // steering (which is to inject only what's new). let (header, closing) = queue::native_steer_framing(); let event_id_hex = event.id.to_hex(); + // Reply anchor for the steered event: without this, the agent's only + // in-context reply instruction is the (stale) anchor from its original + // prompt, so it replies into the old thread even when the new message is + // top-level or in a different thread. See `native_steer_reply_instruction`. + let reply_instruction = queue::native_steer_reply_instruction(&event); let be = queue::BatchEvent { event, prompt_tag: prompt_tag.clone(), received_at: std::time::Instant::now(), }; let event_block = queue::format_event_block(channel_id, None, &be, None); - let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); + let body = format!( + "{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n{reply_instruction}\n\n{closing}" + ); let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); let request = pool::SteerRequest { diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 5a82ad6845..e14b0fc34a 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1545,6 +1545,47 @@ pub(crate) fn native_steer_framing() -> (&'static str, &'static str) { (framing.new_header_single, framing.closing_note) } +/// Build the reply-anchor instruction for a natively-steered event. +/// +/// A native steer injects a *new* event into a live turn, but the only reply +/// instruction in the agent's context is the (now possibly stale) anchor from +/// the original prompt. Without a fresh anchor the agent replies into the old +/// thread even when the steered message is top-level or belongs to a different +/// thread — the exact confusion this section prevents. +/// +/// Anchor derivation mirrors [`resolve_reply_anchor`]: +/// - steered event in a thread → that thread's ROOT +/// - steered event top-level → the event itself (it becomes the new root) +/// +/// Unlike the batch path we have no profile lookup here (the steer body is +/// built synchronously in the main loop), so no agent↔agent exemption is +/// applied. That matches the documented fail-open policy in +/// [`turn_is_human_facing`]: unknown identity is treated as human, because +/// humans must not lose thread visibility to a misclassification. The +/// instruction text is scoped to "replies to this new message", so an agent +/// weaving the steer into unrelated in-progress work keeps its original +/// anchor for that work. +pub(crate) fn native_steer_reply_instruction(event: &Event) -> String { + let tags = parse_thread_tags(event); + let event_id = event.id.to_hex(); + let mut s = String::new(); + match tags.root_event_id { + Some(root) => s.push_str(&format!( + "\nIMPORTANT: For replies to this new message, use `--reply-to {root}` \ + on `buzz messages send` — it belongs to that thread, which may differ \ + from the thread you were working in. Do not reuse the reply anchor \ + from your original prompt for replies to this message." + )), + None => s.push_str(&format!( + "\nIMPORTANT: This new message is top-level (not in your current \ + thread). For replies to it, use `--reply-to {event_id}` on \ + `buzz messages send` — it becomes the root of a new thread. Do NOT \ + reply to it inside the thread you were working in." + )), + } + s +} + #[cfg(test)] mod tests { use super::*; @@ -3394,6 +3435,49 @@ mod tests { ); } + #[test] + fn test_native_steer_reply_instruction_top_level_anchors_to_event() { + // A steered event with no thread tags is top-level: replies to it must + // anchor to the event itself (the new thread root), not the stale + // anchor from the original prompt. + let event = make_event("give me a status report"); + let event_id = event.id.to_hex(); + let instruction = native_steer_reply_instruction(&event); + assert!( + instruction.contains(&format!("--reply-to {event_id}")), + "top-level steer must anchor replies to the steered event: {instruction}" + ); + assert!( + instruction.contains("top-level"), + "instruction should flag the message as top-level: {instruction}" + ); + } + + #[test] + fn test_native_steer_reply_instruction_threaded_anchors_to_root() { + // A steered event carrying thread tags anchors replies to ITS root — + // which may differ from the thread the in-flight turn was working in. + let root = "a".repeat(64); + let parent = "b".repeat(64); + let event = make_event_with_tags( + "steer into another thread", + vec![ + vec!["e".into(), root.clone(), "".into(), "root".into()], + vec!["e".into(), parent, "".into(), "reply".into()], + ], + ); + let event_id = event.id.to_hex(); + let instruction = native_steer_reply_instruction(&event); + assert!( + instruction.contains(&format!("--reply-to {root}")), + "threaded steer must anchor replies to the steered event's thread root: {instruction}" + ); + assert!( + !instruction.contains(&format!("--reply-to {event_id}")), + "threaded steer must not anchor to the event itself: {instruction}" + ); + } + #[test] fn test_drain_channel_removes_pending_events() { let mut q = EventQueue::new(DedupMode::Queue);