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
9 changes: 8 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve DM reply anchoring in native steers

When the in-flight channel is a DM, the normal prompt path applies DM-specific routing: top-level DM messages intentionally get no --reply-to, and DM thread replies anchor to the triggering event id rather than the thread root (format_prompt's is_dm branch, also pinned by test_reply_instruction_absent_for_dm_non_reply). This new helper only switches on thread tags and try_native_steer calls it without channel type, so the exact same DM message is routed differently whenever it arrives mid-turn via native steer: top-level DMs become threaded replies, and threaded DMs reply to the root instead of the current message. Please pass the DM/channel context into this derivation or suppress/adjust the instruction for DMs.

Useful? React with 👍 / 👎.

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::*;
Expand Down Expand Up @@ -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);
Expand Down
Loading