fix(buzz-acp): anchor replies for natively-steered events

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 <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
npub1ty04d6th3jzvggd25za6xmqrh99g42kvkvqsmf7ydv793fgyxa3s3t2lw7
2026-07-06 11:06:46 -07:00
parent 2eebeb54bc
commit 885585188f
2 changed files with 92 additions and 1 deletions

View File

@@ -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::<pool::SteerAck>();
let request = pool::SteerRequest {

View File

@@ -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);