Files
buzz/desktop/tests/e2e/send-channel-binding.spec.ts
Taylor Ho 1c8321cd08 fix(desktop): retain automatic mentions only in threads (#7144)
**Category:** fix
**User Impact:** Top-level channel messages now notify only agents
explicitly selected for that message, while thread replies visibly
retain their addressed agents.

**Problem:** Channel-root and thread composers presented the same
automatic-mention model even though retained recipients are only
predictable within an ongoing thread. That could make a new top-level
message notify an agent the sender did not deliberately choose for that
message.

**Solution:** Make retained audiences a thread-only capability. Root
messages remain explicit and one-shot; threads retain visible, removable
agent recipients, with the automatic-mention setting exposed directly in
the mention picker.

<details>
<summary>File changes</summary>

**desktop/src/features/channels/ui/ChannelPane.tsx**
Removes persistent audience state from the channel-root composer.

**desktop/src/features/messages/ui/ComposerAddressControls.tsx**
Uses the broader **Manage mentions** label because the picker includes
people as well as automatic agent controls.

**desktop/src/features/messages/ui/ComposerAddressControls.test.mjs**
Locks the updated accessible label and active treatment.

**desktop/src/features/messages/ui/MentionAutocomplete.tsx**
Shows the right-aligned automatic-mention setting directly, uses
thread-specific copy, preserves keyboard/focus behavior, and keeps the
current mention when retention is unchecked.

**desktop/src/features/messages/ui/MentionAutocomplete.test.mjs**
Covers the always-visible setting, compact layout, copy, and
thread-scoped agent actions.

**desktop/src/features/messages/ui/MessageComposer.tsx**
Separates unpinning an agent for future replies from removing its
current draft mention.

**desktop/src/features/messages/ui/MessageComposer.types.ts**
Narrows retained audience contexts to threads.


**desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs**
Prevents channel-root and new-message hosts from opting back into
retained audiences.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**
Splits unpin and current-mention removal semantics.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs**
Verifies unpinning retains the current draft mention.

**desktop/src/features/settings/ui/AgentsSettingsPanel.tsx**
Describes the preference as addressing selected agents in thread
replies.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Moves retained-audience lifecycle coverage to thread composers and adds
root, settings, layout, focus, keyboard, unpin, and draft regressions.

**desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx**
Preserves composer focus ownership while routing the thread-only
controls.

**desktop/src/features/messages/ui/useComposerFocusOwnership.ts**
Keeps focus within the composer while interacting with its mention
overlay controls.

</details>

### Reproduction steps

1. Enable **Automatically mention agents** under agent settings.
2. In a channel root, select an agent and send a message. Confirm the
agent is addressed once, no retained-recipient control appears, and the
next root message has no agent recipient.
3. Open a thread and select an agent. Confirm the visible recipient
persists into later replies.
4. Open **Manage mentions** in the thread composer. Confirm the
automatic-mention setting is immediately visible, right-aligned, and
labeled **Address selected agents in thread replies**.
5. Uncheck a selected agent. Confirm its current draft mention remains,
while later replies no longer retain it automatically.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-09-01 16:07:35 -07:00

191 lines
6.7 KiB
TypeScript

/**
* E2E regression for the wrong-channel send bug.
*
* Repro: compose a message in channel A that tags a non-member managed agent
* (which forces the slow `add_channel_members` path), submit, then immediately
* switch to channel B before the agent-attach await resolves. Without the fix,
* the message lands in B's timeline; with the fix it must land in A's.
*
* The `addChannelMembersDelayMs` bridge knob holds the `add_channel_members`
* handler open long enough for the channel click to race the in-flight send.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// A managed agent that is NOT a member of any channel in the seed data.
const OUT_OF_CHANNEL_BOT_PUBKEY =
"ee00000000000000000000000000000000000000000000000000000000000001";
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
function autocomplete(page: import("@playwright/test").Page) {
return page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete");
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? []
);
});
}
function commandCount(commands: string[], command: string) {
return commands.filter((c) => c === command).length;
}
// The channel timeline renders off a `useDeferredValue` snapshot; poll for the
// pending marker to clear before asserting on freshly-sent content.
async function waitForTimelineSettled(page: import("@playwright/test").Page) {
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
}
// ---------------------------------------------------------------------------
// Main regression: message always lands in the compose-time channel
// ---------------------------------------------------------------------------
test("message with agent mention lands in compose-time channel despite mid-send navigation", async ({
page,
}) => {
const MESSAGE_TEXT = `send-binding-repro-${Date.now()}`;
// Install bridge with:
// - a managed agent that is NOT in general (forces add_channel_members path)
// - a 500ms delay on add_channel_members to open the race window
await installMockBridge(page, {
addChannelMembersDelayMs: 500,
managedAgents: [
{
pubkey: OUT_OF_CHANNEL_BOT_PUBKEY,
name: "BotA",
status: "running",
// No channelNames → agent is not a member of any channel
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Type a message that mentions the out-of-channel agent
const input = page.getByTestId("message-input");
await input.fill("@BotA");
const dropdown = autocomplete(page);
const botRow = dropdown.locator("button", { hasText: "BotA" });
await expect(botRow).toBeVisible();
await expect(botRow.getByText("not in channel")).toBeVisible();
// Select BotA from the autocomplete
await input.press("Enter");
await page.keyboard.type(` ${MESSAGE_TEXT}`);
// Verify the inline mention is present without creating thread-retained state.
await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`);
await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA");
await expect(
page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`),
).toHaveCount(0);
// Snapshot the baseline command count before sending
const baselineCommands = await readCommandLog(page);
const baselineAddCount = commandCount(
baselineCommands,
"add_channel_members",
);
// Submit the message — this triggers the async add_channel_members path
await page.getByTestId("send-message").click();
// Immediately switch to channel-agents BEFORE the 500ms delay resolves.
// This is the race the fix closes.
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Wait for add_channel_members to fire (confirms the race window opened and
// the fix's captured channel id was used for the agent-attach call).
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "add_channel_members"),
)
.toBeGreaterThan(baselineAddCount);
// Let the in-flight send finish (500ms delay + buffer).
await page.waitForTimeout(800);
// --- Assert message landed in general (compose-time channel) ---
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForTimelineSettled(page);
// The message must appear in general's timeline (not the switched-to channel).
await expect(page.getByTestId("message-timeline")).toContainText(
MESSAGE_TEXT,
);
// --- Assert message did NOT land in agents (switched-to channel) ---
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await waitForTimelineSettled(page);
await expect(page.getByTestId("message-timeline")).not.toContainText(
MESSAGE_TEXT,
);
});
// ---------------------------------------------------------------------------
// Invariant: without mid-send navigation, normal agent-mention send still works
// ---------------------------------------------------------------------------
test("message with agent mention delivers correctly when no channel switch occurs", async ({
page,
}) => {
const MESSAGE_TEXT = `no-switch-verify-${Date.now()}`;
await installMockBridge(page, {
addChannelMembersDelayMs: 0,
managedAgents: [
{
pubkey: OUT_OF_CHANNEL_BOT_PUBKEY,
name: "BotA",
status: "running",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("@BotA");
const dropdown = autocomplete(page);
await expect(dropdown.locator("button", { hasText: "BotA" })).toBeVisible();
await input.press("Enter");
await page.keyboard.type(` ${MESSAGE_TEXT}`);
const baselineCommands = await readCommandLog(page);
const baselineAddCount = commandCount(
baselineCommands,
"add_channel_members",
);
await page.getByTestId("send-message").click();
// Wait for the agent-attach step to complete before asserting the timeline.
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "add_channel_members"),
)
.toBeGreaterThan(baselineAddCount);
await waitForTimelineSettled(page);
await expect(page.getByTestId("message-timeline")).toContainText(
MESSAGE_TEXT,
);
});