mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(buzz-acp): human-aware reply anchoring to keep threads flat (#1281)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -34,11 +34,13 @@ Run `buzz --help` or `buzz <group> --help` for full usage.
|
||||
|
||||
### Threading
|
||||
|
||||
- **To a human** (updates, questions, deliverables): Use `--reply-to <thread-root-id>` (from your `[Context]` block) and `@mention` the human. Keeps messages at layer 1 where humans read.
|
||||
- **To another agent** (dispatching, collaborating): Thread however you want.
|
||||
- **When in doubt**, reply to thread root.
|
||||
- **Thread scope:** Respond in the thread where you were tagged. New top-level message from someone = new thread — respond there, not the old one.
|
||||
- **New topic → new top-level message.** Don't graft unrelated work onto an existing thread.
|
||||
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 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.
|
||||
|
||||
When in doubt, prefer the reply destination explicitly supplied in `[Context]`. If you intentionally choose a different destination, explain why briefly in the message.
|
||||
|
||||
### General
|
||||
|
||||
|
||||
@@ -1627,6 +1627,24 @@ fn collect_prompt_pubkeys(
|
||||
pubkeys
|
||||
}
|
||||
|
||||
/// Detect whether a kind:0 profile event belongs to an owned agent.
|
||||
///
|
||||
/// Agents carry a NIP-OA `["auth", owner_pk, conditions, sig]` tag in their
|
||||
/// profile; humans do not. This checks for the tag's presence/shape only — a
|
||||
/// cheap routing heuristic for reply anchoring, not a verified security gate
|
||||
/// (the signing path in `lib.rs::check_sibling_via_profile` does full
|
||||
/// verification where it matters).
|
||||
fn profile_event_is_agent(ev: &serde_json::Value) -> bool {
|
||||
ev.get("tags")
|
||||
.and_then(|t| t.as_array())
|
||||
.is_some_and(|tags| {
|
||||
tags.iter().any(|tag| {
|
||||
tag.as_array()
|
||||
.is_some_and(|parts| parts.len() == 4 && parts[0].as_str() == Some("auth"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse kind:0 profile events into a `PromptProfileLookup`.
|
||||
///
|
||||
/// Each kind:0 event has `pubkey` and JSON `content` with optional fields:
|
||||
@@ -1649,11 +1667,13 @@ fn parse_kind0_profile_lookup(json: serde_json::Value) -> Option<PromptProfileLo
|
||||
.get("nip05")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let is_agent = profile_event_is_agent(ev);
|
||||
lookup.insert(
|
||||
pk.to_ascii_lowercase(),
|
||||
PromptProfile {
|
||||
display_name,
|
||||
nip05_handle,
|
||||
is_agent,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2784,10 +2804,33 @@ mod tests {
|
||||
Some(&PromptProfile {
|
||||
display_name: Some("Wes".into()),
|
||||
nip05_handle: Some("wes@example.com".into()),
|
||||
is_agent: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_event_is_agent_detects_nip_oa_auth_tag() {
|
||||
// Agent profile carries a 4-element NIP-OA ["auth", owner, cond, sig] tag.
|
||||
let agent_ev = json!({
|
||||
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"tags": [["auth", "owner_pk", "conditions", "sig"]],
|
||||
});
|
||||
assert!(profile_event_is_agent(&agent_ev));
|
||||
|
||||
// Human profile: no auth tag.
|
||||
let human_ev = json!({ "pubkey": "bbbb", "tags": [["t", "topic"]] });
|
||||
assert!(!profile_event_is_agent(&human_ev));
|
||||
|
||||
// Empty / missing tags → not an agent.
|
||||
assert!(!profile_event_is_agent(&json!({ "tags": [] })));
|
||||
assert!(!profile_event_is_agent(&json!({})));
|
||||
|
||||
// Malformed auth tag (wrong arity) → not treated as an agent.
|
||||
let malformed = json!({ "tags": [["auth", "owner_pk"]] });
|
||||
assert!(!profile_event_is_agent(&malformed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_kind0_profile_lookup_returns_none_for_empty() {
|
||||
assert!(parse_kind0_profile_lookup(json!([])).is_none());
|
||||
|
||||
+240
-34
@@ -736,6 +736,10 @@ pub struct PromptChannelInfo {
|
||||
pub struct PromptProfile {
|
||||
pub display_name: Option<String>,
|
||||
pub nip05_handle: Option<String>,
|
||||
/// True when this pubkey's kind:0 profile carries a NIP-OA `auth` tag,
|
||||
/// i.e. it is an owned agent rather than a human. Used to gate reply-anchor
|
||||
/// flattening (UX routing heuristic, not a security boundary).
|
||||
pub is_agent: bool,
|
||||
}
|
||||
|
||||
/// Pubkey-keyed profile lookup used while formatting ACP prompts.
|
||||
@@ -881,14 +885,87 @@ fn append_reply_instruction(s: &mut String, event_id: &str) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Append a new-thread reply instruction for a human-facing top-level mention.
|
||||
///
|
||||
/// 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`."
|
||||
));
|
||||
}
|
||||
|
||||
/// Decide whether a turn is human-facing for reply-anchor purposes.
|
||||
///
|
||||
/// A turn is human-facing when the triggering sender is a human, OR a human
|
||||
/// (other than this agent) is tagged in the triggering event. Identity comes
|
||||
/// from `PromptProfile::is_agent` (NIP-OA auth tag), not raw `p`-tag presence:
|
||||
/// agent-only mentions must not force flattening. When a participant cannot be
|
||||
/// classified (no profile fetched), it is treated as human — humans must not
|
||||
/// lose thread visibility to a misclassification.
|
||||
fn turn_is_human_facing(
|
||||
sender_pubkey: &str,
|
||||
thread_tags: &ThreadTags,
|
||||
profile_lookup: Option<&PromptProfileLookup>,
|
||||
) -> bool {
|
||||
let is_agent = |pubkey: &str| -> bool {
|
||||
profile_lookup
|
||||
.and_then(|m| m.get(&normalize_lookup_key(pubkey)))
|
||||
.map(|p| p.is_agent)
|
||||
// Unknown identity → treat as human (fail open for visibility).
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !is_agent(sender_pubkey) {
|
||||
return true;
|
||||
}
|
||||
thread_tags.mentioned_pubkeys.iter().any(|pk| !is_agent(pk))
|
||||
}
|
||||
|
||||
/// 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 `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<String> {
|
||||
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()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn format_context_hints(
|
||||
channel_id: Uuid,
|
||||
channel_info: Option<&PromptChannelInfo>,
|
||||
thread_tags: &ThreadTags,
|
||||
is_dm: bool,
|
||||
has_conversation_context: bool,
|
||||
triggering_event_id: Option<&str>,
|
||||
reply_anchor: Option<&str>,
|
||||
) -> String {
|
||||
let channel_display = match channel_info {
|
||||
Some(ci) => format!("{} (#{channel_id})", ci.name),
|
||||
@@ -924,7 +1001,7 @@ fn format_context_hints(
|
||||
s.push_str(&format!("\nParent: {parent}"));
|
||||
}
|
||||
}
|
||||
if let Some(event_id) = triggering_event_id {
|
||||
if let Some(event_id) = reply_anchor {
|
||||
append_reply_instruction(&mut s, event_id);
|
||||
}
|
||||
}
|
||||
@@ -947,17 +1024,21 @@ fn format_context_hints(
|
||||
}
|
||||
}
|
||||
s.push_str(&format!("\n{ctx_hint}"));
|
||||
if let Some(event_id) = triggering_event_id {
|
||||
if let Some(event_id) = reply_anchor {
|
||||
append_reply_instruction(&mut s, event_id);
|
||||
}
|
||||
s
|
||||
} else {
|
||||
format!(
|
||||
let mut s = format!(
|
||||
"[Context]\n\
|
||||
Scope: channel\n\
|
||||
Channel: {channel_display}\n\
|
||||
Hint: Use `buzz messages get --channel <UUID>` for recent messages if needed."
|
||||
)
|
||||
);
|
||||
if let Some(event_id) = reply_anchor {
|
||||
append_new_thread_reply_instruction(&mut s, event_id);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1085,11 +1166,26 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Context hints (with reply instruction for thread replies).
|
||||
let triggering_event_id = if thread_tags.root_event_id.is_some() {
|
||||
Some(last_event.event.id.to_hex())
|
||||
// 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)
|
||||
// - 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.
|
||||
let sender_pubkey = last_event.event.pubkey.to_hex();
|
||||
let reply_anchor = if is_dm {
|
||||
thread_tags
|
||||
.root_event_id
|
||||
.is_some()
|
||||
.then(|| last_event.event.id.to_hex())
|
||||
} else {
|
||||
None
|
||||
resolve_reply_anchor(
|
||||
&sender_pubkey,
|
||||
&thread_tags,
|
||||
&last_event.event.id.to_hex(),
|
||||
args.profile_lookup,
|
||||
)
|
||||
};
|
||||
sections.push(format_context_hints(
|
||||
batch.channel_id,
|
||||
@@ -1097,7 +1193,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
|
||||
&thread_tags,
|
||||
is_dm,
|
||||
args.conversation_context.is_some(),
|
||||
triggering_event_id.as_deref(),
|
||||
reply_anchor.as_deref(),
|
||||
));
|
||||
|
||||
// 3. Conversation context (thread or DM).
|
||||
@@ -2401,6 +2497,7 @@ mod tests {
|
||||
PromptProfile {
|
||||
display_name: Some("Wes".into()),
|
||||
nip05_handle: None,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -2408,6 +2505,7 @@ mod tests {
|
||||
PromptProfile {
|
||||
display_name: Some("Rick".into()),
|
||||
nip05_handle: None,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]);
|
||||
@@ -2437,6 +2535,7 @@ mod tests {
|
||||
PromptProfile {
|
||||
display_name: None,
|
||||
nip05_handle: Some("wes@example.com".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
assert_eq!(
|
||||
@@ -2453,6 +2552,7 @@ mod tests {
|
||||
PromptProfile {
|
||||
display_name: Some(" ".into()),
|
||||
nip05_handle: Some("wes@example.com".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
assert_eq!(
|
||||
@@ -2461,6 +2561,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Human-aware reply anchoring ──────────────────────────────────────────
|
||||
|
||||
const HUMAN_PK: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const AGENT_A_PK: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
||||
const AGENT_B_PK: &str = "3333333333333333333333333333333333333333333333333333333333333333";
|
||||
const ROOT_ID: &str = "abc0000000000000000000000000000000000000000000000000000000000000";
|
||||
const TRIGGER_ID: &str = "def0000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
fn profile(is_agent: bool) -> PromptProfile {
|
||||
PromptProfile {
|
||||
is_agent,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup with HUMAN as a human and AGENT_A / AGENT_B as agents.
|
||||
fn id_lookup() -> PromptProfileLookup {
|
||||
HashMap::from([
|
||||
(HUMAN_PK.to_string(), profile(false)),
|
||||
(AGENT_A_PK.to_string(), profile(true)),
|
||||
(AGENT_B_PK.to_string(), profile(true)),
|
||||
])
|
||||
}
|
||||
|
||||
fn thread_tags(root: Option<&str>, mentions: &[&str]) -> ThreadTags {
|
||||
ThreadTags {
|
||||
root_event_id: root.map(str::to_string),
|
||||
parent_event_id: root.map(str::to_string),
|
||||
mentioned_pubkeys: mentions.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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()));
|
||||
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.
|
||||
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));
|
||||
}
|
||||
|
||||
#[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()));
|
||||
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()));
|
||||
assert_eq!(anchor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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()));
|
||||
assert_eq!(anchor.as_deref(), Some(ROOT_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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchor_agent_only_p_tags_do_not_flatten() {
|
||||
// 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()));
|
||||
assert_eq!(anchor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_prompt_label_strips_newlines_and_control_chars() {
|
||||
assert_eq!(
|
||||
@@ -2936,9 +3124,8 @@ mod tests {
|
||||
let root_id = "a".repeat(64);
|
||||
let event = make_event_with_tags(
|
||||
"@bot help",
|
||||
vec![vec!["e".into(), root_id, "".into(), "reply".into()]],
|
||||
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
|
||||
);
|
||||
let event_id = event.id.to_hex();
|
||||
let batch = FlushBatch {
|
||||
channel_id: ch,
|
||||
events: vec![BatchEvent {
|
||||
@@ -2949,10 +3136,13 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
// 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.
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"channel thread reply should include reply instruction with triggering event ID"
|
||||
prompt.contains(&format!("--reply-to {root_id}")),
|
||||
"human-facing thread reply should anchor to the thread root"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("For ordinary replies in this turn"),
|
||||
@@ -3006,9 +3196,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reply_instruction_absent_for_top_level_channel_message() {
|
||||
fn test_reply_instruction_present_for_top_level_human_message() {
|
||||
let ch = Uuid::new_v4();
|
||||
let event = make_event("hello world");
|
||||
let event_id = event.id.to_hex();
|
||||
let batch = FlushBatch {
|
||||
channel_id: ch,
|
||||
events: vec![BatchEvent {
|
||||
@@ -3019,10 +3210,17 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
// 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.
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
!prompt.contains("--reply-to"),
|
||||
"top-level message should NOT include reply instruction"
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"top-level human message should anchor a new thread at the triggering event"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("new top-level message"),
|
||||
"top-level human message should use the new-thread instruction"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3059,7 +3257,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reply_instruction_uses_triggering_event_id_not_root_or_parent() {
|
||||
fn test_human_thread_reply_anchors_to_root_not_triggering_or_parent() {
|
||||
let ch = Uuid::new_v4();
|
||||
let root_id = "a".repeat(64);
|
||||
let parent_id = "b".repeat(64);
|
||||
@@ -3081,19 +3279,20 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
// Human-facing (no lookup) deep reply: anchor to the thread ROOT to
|
||||
// keep the conversation flat — NOT the triggering event or parent.
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
// The instruction should use the triggering event's own ID — not root or parent.
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"nested reply instruction should use the triggering event ID"
|
||||
prompt.contains(&format!("--reply-to {root_id}")),
|
||||
"human-facing nested reply should anchor to the thread root"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains(&format!("--reply-to {root_id}")),
|
||||
"instruction should NOT use root_event_id"
|
||||
!prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"instruction should NOT anchor to the triggering event id"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains(&format!("--reply-to {parent_id}")),
|
||||
"instruction should NOT use parent_event_id from tags"
|
||||
"instruction should NOT anchor to the parent event id"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3103,9 +3302,8 @@ mod tests {
|
||||
let root_id = "e".repeat(64);
|
||||
let event = make_event_with_tags(
|
||||
"@bot post your summary in the channel root",
|
||||
vec![vec!["e".into(), root_id, "".into(), "reply".into()]],
|
||||
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
|
||||
);
|
||||
let event_id = event.id.to_hex();
|
||||
let batch = FlushBatch {
|
||||
channel_id: ch,
|
||||
events: vec![BatchEvent {
|
||||
@@ -3118,8 +3316,8 @@ mod tests {
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"thread reply should still provide the default reply target"
|
||||
prompt.contains(&format!("--reply-to {root_id}")),
|
||||
"human-facing thread reply should anchor to the thread root"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("channel-root, top-level"),
|
||||
@@ -3138,9 +3336,8 @@ mod tests {
|
||||
let root_id = "c".repeat(64);
|
||||
let threaded = make_event_with_tags(
|
||||
"@bot help",
|
||||
vec![vec!["e".into(), root_id, "".into(), "reply".into()]],
|
||||
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
|
||||
);
|
||||
let threaded_id = threaded.id.to_hex();
|
||||
let batch = FlushBatch {
|
||||
channel_id: ch,
|
||||
events: vec![
|
||||
@@ -3158,10 +3355,12 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
// Scope derives from the last (threaded) event; human-facing → anchor
|
||||
// to that thread's root.
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {threaded_id}")),
|
||||
"batched prompt should use last (threaded) event's ID"
|
||||
prompt.contains(&format!("--reply-to {root_id}")),
|
||||
"batched prompt should anchor to the last (threaded) event's root"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3174,6 +3373,7 @@ mod tests {
|
||||
vec![vec!["e".into(), root_id, "".into(), "reply".into()]],
|
||||
);
|
||||
let plain = make_event("latest top-level");
|
||||
let plain_id = plain.id.to_hex();
|
||||
let batch = FlushBatch {
|
||||
channel_id: ch,
|
||||
events: vec![
|
||||
@@ -3191,10 +3391,16 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
// Last event is top-level and human-facing → opens a new thread
|
||||
// anchored to that top-level event (NOT the earlier thread's root).
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
!prompt.contains("--reply-to"),
|
||||
"batched prompt where last event is top-level should NOT include reply instruction"
|
||||
prompt.contains(&format!("--reply-to {plain_id}")),
|
||||
"batched top-level-last prompt should anchor to the last (top-level) event"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("new top-level message"),
|
||||
"batched top-level-last prompt should use the new-thread instruction"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export default defineConfig({
|
||||
"**/sidebar-more-unread-overlap.spec.ts",
|
||||
"**/home-collapsed-top-chrome.spec.ts",
|
||||
"**/thread-unread.spec.ts",
|
||||
"**/thread-reply-anchor-roleplay.spec.ts",
|
||||
"**/animated-avatar.spec.ts",
|
||||
"**/reminders.spec.ts",
|
||||
"**/virtualization.spec.ts",
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/thread-reply-anchor-roleplay";
|
||||
const SELF_PUBKEY = "deadbeef".repeat(8);
|
||||
const CHANNEL = "general";
|
||||
|
||||
type MockMessageEvent = {
|
||||
id: string;
|
||||
created_at: number;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
async function waitForMockLiveSubscription(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return page.evaluate(
|
||||
({ ch }) =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
||||
channelName: string;
|
||||
}) => boolean;
|
||||
}
|
||||
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
|
||||
false,
|
||||
{ ch: channelName },
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function emitMockMessage(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
content: string,
|
||||
options?: {
|
||||
parentEventId?: string | null;
|
||||
pubkey?: string;
|
||||
createdAt?: number;
|
||||
mentionPubkeys?: string[];
|
||||
},
|
||||
): Promise<MockMessageEvent> {
|
||||
const event = await page.evaluate(
|
||||
({ ch, msg, parentEventId, pubkey, ts, mentionPubkeys }) => {
|
||||
return (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
pubkey?: string;
|
||||
createdAt?: number;
|
||||
mentionPubkeys?: string[];
|
||||
}) => { id: string; created_at: number; pubkey: string };
|
||||
}
|
||||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: ch,
|
||||
content: msg,
|
||||
parentEventId,
|
||||
pubkey,
|
||||
createdAt: ts,
|
||||
mentionPubkeys,
|
||||
});
|
||||
},
|
||||
{
|
||||
ch: channelName,
|
||||
msg: content,
|
||||
parentEventId: options?.parentEventId ?? null,
|
||||
pubkey: options?.pubkey ?? SELF_PUBKEY,
|
||||
ts: options?.createdAt,
|
||||
mentionPubkeys: options?.mentionPubkeys,
|
||||
},
|
||||
);
|
||||
if (!event) {
|
||||
throw new Error("Mock message emitter is not installed");
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
async function setupRoleplayChannel(page: import("@playwright/test").Page) {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
name: "Pinky",
|
||||
respondTo: "anyone",
|
||||
channelNames: [CHANNEL],
|
||||
status: "online",
|
||||
},
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
||||
name: "Brain",
|
||||
respondTo: "anyone",
|
||||
channelNames: [CHANNEL],
|
||||
status: "online",
|
||||
},
|
||||
],
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
displayName: "Pinky",
|
||||
isAgent: true,
|
||||
},
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
||||
displayName: "Brain",
|
||||
isAgent: true,
|
||||
},
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
displayName: "Wes",
|
||||
isAgent: false,
|
||||
},
|
||||
{
|
||||
pubkey: SELF_PUBKEY,
|
||||
displayName: "Nora",
|
||||
isAgent: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL);
|
||||
await waitForMockLiveSubscription(page, CHANNEL);
|
||||
}
|
||||
|
||||
async function openThread(page: import("@playwright/test").Page) {
|
||||
const summary = page.getByTestId("message-thread-summary").first();
|
||||
await expect(summary).toBeVisible();
|
||||
await summary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expandReply(
|
||||
page: import("@playwright/test").Page,
|
||||
replyId: string,
|
||||
) {
|
||||
const replies = page
|
||||
.getByTestId("message-thread-replies")
|
||||
.getByTestId("message-row");
|
||||
const before = await replies.count();
|
||||
await page.locator(`[data-thread-head-id="${replyId}"]`).click();
|
||||
await expect.poll(() => replies.count()).toBeGreaterThan(before);
|
||||
}
|
||||
|
||||
async function screenshotThreadPanel(
|
||||
page: import("@playwright/test").Page,
|
||||
path: string,
|
||||
) {
|
||||
const panel = page.getByTestId("message-thread-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await page.mouse.move(360, 24);
|
||||
await page.waitForTimeout(100);
|
||||
await panel.screenshot({ path });
|
||||
}
|
||||
|
||||
test.describe("thread reply anchor A/B roleplay screenshots", () => {
|
||||
test("01-baseline-human-reply-nests-agent-at-depth-2", async ({ page }) => {
|
||||
await setupRoleplayChannel(page);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const root = await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Wes: @Pinky please review the checkout copy.",
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
||||
createdAt: now,
|
||||
},
|
||||
);
|
||||
const humanReply = await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Nora: adding context — this is only about the receipt screen.",
|
||||
{
|
||||
parentEventId: root.id,
|
||||
pubkey: SELF_PUBKEY,
|
||||
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
||||
createdAt: now + 1,
|
||||
},
|
||||
);
|
||||
|
||||
// Baseline queue.rs anchored the agent response to the triggering human
|
||||
// reply, producing depth 2 under Nora's message.
|
||||
await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Pinky: Got it — I’ll check the receipt copy only. Narf!",
|
||||
{
|
||||
parentEventId: humanReply.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey, SELF_PUBKEY],
|
||||
createdAt: now + 2,
|
||||
},
|
||||
);
|
||||
|
||||
await openThread(page);
|
||||
await expandReply(page, humanReply.id);
|
||||
await expect(page.getByText("Nora: adding context")).toBeVisible();
|
||||
await expect(page.getByText("Pinky: Got it")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("message-thread-replies").getByTestId("message-row"),
|
||||
).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(2);
|
||||
|
||||
await screenshotThreadPanel(page, `${SHOTS}/01-baseline-depth-2.png`);
|
||||
});
|
||||
|
||||
test("02-patched-human-reply-flattens-agent-at-root", async ({ page }) => {
|
||||
await setupRoleplayChannel(page);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const root = await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Wes: @Pinky please review the checkout copy.",
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
||||
createdAt: now,
|
||||
},
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Nora: adding context — this is only about the receipt screen.",
|
||||
{
|
||||
parentEventId: root.id,
|
||||
pubkey: SELF_PUBKEY,
|
||||
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
||||
createdAt: now + 1,
|
||||
},
|
||||
);
|
||||
|
||||
// Patched queue.rs anchors the agent response to the thread root, keeping
|
||||
// both human and agent replies as flat layer-1 siblings.
|
||||
await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Pinky: Got it — I’ll check the receipt copy only. Narf!",
|
||||
{
|
||||
parentEventId: root.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey, SELF_PUBKEY],
|
||||
createdAt: now + 2,
|
||||
},
|
||||
);
|
||||
|
||||
await openThread(page);
|
||||
await expect(page.getByText("Nora: adding context")).toBeVisible();
|
||||
await expect(page.getByText("Pinky: Got it")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("message-thread-replies").getByTestId("message-row"),
|
||||
).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0);
|
||||
|
||||
await screenshotThreadPanel(page, `${SHOTS}/02-patched-flat-l1.png`);
|
||||
});
|
||||
|
||||
test("03-patched-top-level-human-starts-thread-at-human-root", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupRoleplayChannel(page);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const humanRoot = await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Wes: @Pinky start the inventory audit.",
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
||||
createdAt: now,
|
||||
},
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Pinky: Starting the audit and I’ll report back here. Poit!",
|
||||
{
|
||||
parentEventId: humanRoot.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
|
||||
createdAt: now + 1,
|
||||
},
|
||||
);
|
||||
|
||||
await openThread(page);
|
||||
await expect(page.getByText("Pinky: Starting the audit")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("message-thread-replies").getByTestId("message-row"),
|
||||
).toHaveCount(1);
|
||||
|
||||
await screenshotThreadPanel(page, `${SHOTS}/03-top-level-human-root.png`);
|
||||
});
|
||||
|
||||
test("04-agent-only-branch-keeps-deeper-nesting", async ({ page }) => {
|
||||
await setupRoleplayChannel(page);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const root = await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Pinky: @Brain I found a failing visual case.",
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.charlie.pubkey],
|
||||
createdAt: now,
|
||||
},
|
||||
);
|
||||
const brainReply = await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Brain: Check the anchor emitted by queue.rs.",
|
||||
{
|
||||
parentEventId: root.id,
|
||||
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
|
||||
createdAt: now + 1,
|
||||
},
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
CHANNEL,
|
||||
"Pinky: Good catch — agent-only branches can stay nested. Zort!",
|
||||
{
|
||||
parentEventId: brainReply.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkeys: [TEST_IDENTITIES.charlie.pubkey],
|
||||
createdAt: now + 2,
|
||||
},
|
||||
);
|
||||
|
||||
await openThread(page);
|
||||
await expandReply(page, brainReply.id);
|
||||
await expect(page.getByText("Brain: Check the anchor")).toBeVisible();
|
||||
await expect(page.getByText("Pinky: Good catch")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("message-thread-replies").getByTestId("message-row"),
|
||||
).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(2);
|
||||
|
||||
await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-nested.png`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user