diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 5d75ca4d0a7..c3b3ec35b18 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -34,11 +34,13 @@ Run `buzz --help` or `buzz --help` for full usage. ### Threading -- **To a human** (updates, questions, deliverables): Use `--reply-to ` (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 diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d67cd7309dc..523d5cf7455 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -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, pub nip05_handle: Option, + /// 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 { + 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 ` 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) -> Vec 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" ); } diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 018ac1471b2..fc6b1386dee 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts new file mode 100644 index 00000000000..6b12aaea6aa --- /dev/null +++ b/desktop/tests/e2e/thread-reply-anchor-roleplay.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 { + 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`); + }); +});