Script cross-channel announcement demo story

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-07-17 14:05:44 -04:00
parent d16d405a64
commit 808dabd0c4
3 changed files with 398 additions and 6 deletions
@@ -66,6 +66,55 @@ export const ANNOUNCEMENT_DEMO_AGENTS = [
export type AnnouncementDemoAgentName =
(typeof ANNOUNCEMENT_DEMO_AGENTS)[number]["name"];
/**
* Cross-channel cues for the short announcement film. Opening the trigger
* channel starts real observer-derived turns in the background channels, then
* delivers a live mention in the pivot channel. The mention is also inserted
* into the Home feed so the production notification path owns the native
* notification and click-through behavior.
*/
export const ANNOUNCEMENT_DEMO_STORY = {
triggerChannelName: "flight-path",
pivot: {
delayMs: 8_000,
channelName: "engineering",
author: "engineer",
content:
"@Alex Rivera — before this prototype gets any bigger, we should talk stack. We’re hand-rolling the iOS and Android views separately and it’s already drifting.",
},
engineeringSequence: {
insight: {
delayMs: 2_500,
author: "data",
content:
"Honestly? Move it to Flutter now. One codebase, both platforms, and we stop maintaining two of everything. Cheap to switch today, expensive in a month.",
},
retask: {
delayMs: 4_000,
content:
"@Bumble New direction, team — we’re moving the prototype to Flutter. Can you and the others re-plan the port and start scaffolding?",
},
agentReplies: {
Bumble:
"🫡 On it — mapping the current React views to Flutter widgets now. Handing the state layer to @Fizz.",
Fizz: "Got the state layer — porting the game loop to Flutter, keeping the tuned physics. @Honey, UI shell?",
Honey:
"Building the Flutter widget tree from the existing designs — same look, one codebase. 🍯",
},
humanClose: {
delayMs: 1_100,
author: "data",
content:
"That’s the version. One stack, both platforms — nice pivot, team. 🐝",
},
},
workingTurns: [
{ channelName: "design", agent: "Honey" },
{ channelName: "engineering", agent: "Bumble" },
{ channelName: "marketing", agent: "Fizz" },
],
} as const;
export const ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS = [
{
channelName: "flight-path",
+203 -6
View File
@@ -58,6 +58,7 @@ import {
ANNOUNCEMENT_DEMO_PEOPLE,
ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS,
ANNOUNCEMENT_DEMO_PROJECTS,
ANNOUNCEMENT_DEMO_STORY,
type AnnouncementDemoAgentName,
type AnnouncementDemoPersonKey,
} from "@/testing/announcementDemoFixtures";
@@ -135,6 +136,8 @@ type E2eConfig = {
mode?: "mock" | "relay";
mock?: {
announcementDemo?: boolean;
/** Disable the cross-channel announcement story while retaining standalone demo fixtures. */
announcementDemoStory?: boolean;
acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[];
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
@@ -2748,6 +2751,8 @@ const realSockets = new Map<number, WebSocket>();
let mockManagedAgents: MockManagedAgent[] = [];
const pendingAnnouncementDemoAgentResponses = new Set<string>();
const startedAnnouncementDemoLiveConversations = new Set<string>();
let startedAnnouncementDemoStory = false;
let announcementDemoStorySeq = Date.now();
let mockGlobalAgentConfig: MockGlobalAgentConfig = {
env_vars: {},
provider: null,
@@ -4446,6 +4451,21 @@ async function requestAnnouncementDemoAgentReply(
const provider = getAnnouncementDemoProviderConfig(agent);
// The local Vite proxy keeps provider credentials out of the webview while
// still allowing the native demo to use the settings entered in the app.
const scriptedReply =
sourceEvent.tags.some(
(tag) => tag[0] === "announcement-demo-story" && tag[1] === "retask",
) ||
sourceEvent.tags.some(
(tag) => tag[0] === "announcement-demo-story" && tag[1] === "handoff",
)
? ANNOUNCEMENT_DEMO_STORY.engineeringSequence.agentReplies[
agent.name as AnnouncementDemoAgentName
]
: null;
if (scriptedReply) {
return scriptedReply;
}
const response = await fetch("/__announcement-demo/agent-response", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -4509,6 +4529,15 @@ function publishAnnouncementDemoAgentReply(
: buildTopLevelMessageTags(channelId, mentionPubkeys, agent.pubkey);
if (isAgentChainEvent && continueAgentChain) {
tags.push([ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, "1"]);
if (
sourceEvent.tags.some(
(tag) =>
tag[0] === "announcement-demo-story" &&
(tag[1] === "retask" || tag[1] === "handoff"),
)
) {
tags.push(["announcement-demo-story", "handoff"]);
}
}
const event = createMockEvent(
9,
@@ -4526,12 +4555,15 @@ function publishAnnouncementDemoAgentReply(
ANNOUNCEMENT_DEMO_AGENT_HANDOFF_DELAY_MS,
);
} else if (isAgentChainEvent && continueAgentChain) {
const humanClose = getAnnouncementDemoLiveConversation(channelId)
?.humanClose ?? {
delayMs: 1_000,
author: "producer" as const,
content: "That’s the version. I can cut to that — nice swarm work 🐝",
};
const humanClose = sourceEvent.tags.some(
(tag) => tag[0] === "announcement-demo-story" && tag[1] === "handoff",
)
? ANNOUNCEMENT_DEMO_STORY.engineeringSequence.humanClose
: (getAnnouncementDemoLiveConversation(channelId)?.humanClose ?? {
delayMs: 1_000,
author: "producer" as const,
content: "That’s the version. I can cut to that — nice swarm work 🐝",
});
const closingAuthorPubkey = getAnnouncementDemoPersonPubkey(
humanClose.author,
);
@@ -4694,7 +4726,172 @@ function queueAnnouncementDemoAgentResponses(
}
}
function startAnnouncementDemoStory() {
if (
startedAnnouncementDemoStory ||
!getConfig()?.mock?.announcementDemo ||
getConfig()?.mock?.announcementDemoStory === false
) {
return;
}
startedAnnouncementDemoStory = true;
const pivotChannel = mockChannels.find(
(channel) => channel.name === ANNOUNCEMENT_DEMO_STORY.pivot.channelName,
);
if (pivotChannel) {
// The cross-channel story owns this channel from the moment the trigger is
// opened. Reserve it before background subscriptions can start the older
// standalone engineering conversation.
startedAnnouncementDemoLiveConversations.add(pivotChannel.id);
}
// Prime the feed notification hook with the empty baseline before the live
// mention arrives. Without this, a cold demo launch can classify the first
// alert as initial-load backlog and intentionally suppress its notification.
window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated"));
for (const cue of ANNOUNCEMENT_DEMO_STORY.workingTurns) {
const channel = mockChannels.find(
(candidate) => candidate.name === cue.channelName,
);
const agent = ANNOUNCEMENT_DEMO_AGENTS.find(
(candidate) => candidate.name === cue.agent,
);
if (!channel || !agent) {
continue;
}
announcementDemoStorySeq += 1;
const turnId = `announcement-demo-story-${cue.channelName}`;
const event = {
seq: announcementDemoStorySeq,
timestamp: new Date().toISOString(),
kind: "turn_started" as const,
agentIndex: 0,
channelId: channel.id,
sessionId: null,
turnId,
payload: null,
};
// Drive the same observer-derived store as a real harness turn. Unlike a
// typing fallback this works for background channels with no ChannelScreen
// mounted, which is exactly what the sidebar overview shot needs.
syncAgentTurnsFromEvents(agent.pubkey, [event]);
syncAgentObserverEvents(agent.pubkey, [event]);
}
const pivot = ANNOUNCEMENT_DEMO_STORY.pivot;
window.setTimeout(() => {
const channel = mockChannels.find(
(candidate) => candidate.name === pivot.channelName,
);
if (!channel) {
return;
}
const authorPubkey = getAnnouncementDemoPersonPubkey(pivot.author);
const viewerPubkey = getMockMemberPubkey(getConfig() ?? undefined);
const backgroundAgent = ANNOUNCEMENT_DEMO_AGENTS.find(
(candidate) => candidate.name === "Bumble",
);
if (backgroundAgent) {
announcementDemoStorySeq += 1;
const completed = {
seq: announcementDemoStorySeq,
timestamp: new Date().toISOString(),
kind: "turn_completed" as const,
agentIndex: 0,
channelId: channel.id,
sessionId: null,
turnId: `announcement-demo-story-${channel.name}`,
payload: null,
};
syncAgentTurnsFromEvents(backgroundAgent.pubkey, [completed]);
syncAgentObserverEvents(backgroundAgent.pubkey, [completed]);
}
// The story owns engineering; opening it after the notification must not
// also start its older standalone demo conversation.
startedAnnouncementDemoLiveConversations.add(channel.id);
const message = emitMockChannelMessage(
channel.id,
pivot.content,
null,
authorPubkey,
9,
[viewerPubkey],
[["announcement-demo-story", "pivot"]],
undefined,
mockEventId(),
);
mockFeedOverrides.mentions.unshift({
id: message.id,
kind: message.kind,
pubkey: message.pubkey,
content: message.content,
created_at: message.created_at,
channel_id: channel.id,
channel_name: channel.name,
tags: message.tags,
category: "mention",
});
// Refetching the feed exercises the production notification formatter,
// sound, native notification, and notification click-through path.
window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated"));
const sequence = ANNOUNCEMENT_DEMO_STORY.engineeringSequence;
window.setTimeout(() => {
emitMockTypingIndicator(
channel.id,
getAnnouncementDemoPersonPubkey(sequence.insight.author),
);
window.setTimeout(() => {
emitMockChannelMessage(
channel.id,
sequence.insight.content,
null,
getAnnouncementDemoPersonPubkey(sequence.insight.author),
9,
undefined,
[["announcement-demo-story", "insight"]],
undefined,
mockEventId(),
);
}, 700);
}, sequence.insight.delayMs);
window.setTimeout(() => {
emitMockTypingIndicator(channel.id, viewerPubkey);
window.setTimeout(() => {
const bumble = ANNOUNCEMENT_DEMO_AGENTS.find(
(candidate) => candidate.name === "Bumble",
);
if (!bumble) {
return;
}
emitMockChannelMessage(
channel.id,
sequence.retask.content,
null,
viewerPubkey,
9,
[bumble.pubkey],
[
[ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, "1"],
["announcement-demo-story", "retask"],
],
undefined,
mockEventId(),
);
}, 900);
}, sequence.retask.delayMs);
}, pivot.delayMs);
}
function maybeStartAnnouncementDemoLiveConversation(channelId: string) {
const channel = mockChannels.find((candidate) => candidate.id === channelId);
if (channel?.name === ANNOUNCEMENT_DEMO_STORY.triggerChannelName) {
startAnnouncementDemoStory();
}
if (
startedAnnouncementDemoLiveConversations.has(channelId) ||
!getConfig()?.mock?.announcementDemo
+146
View File
@@ -61,6 +61,14 @@ test("announcement demo loads its workspace, people, and projects", async ({
});
});
await page.goto("/?demo=announcement");
await page.evaluate(() => {
const config = (
window as Window & {
__BUZZ_E2E__?: { mock?: { announcementDemoStory?: boolean } };
}
).__BUZZ_E2E__;
if (config?.mock) config.mock.announcementDemoStory = false;
});
await page.waitForFunction(
() =>
typeof (
@@ -358,3 +366,141 @@ test("announcement demo loads its workspace, people, and projects", async ({
).toBeVisible();
}
});
test("announcement story shows background work and pivots through a mention", async ({
context,
page,
}) => {
test.setTimeout(30_000);
await context.grantPermissions(["notifications"]);
// The E2E bridge exposes Tauri internals, but Chromium reports Linux. Force
// the browser notification branch so its click target is inspectable here;
// the packaged Linux build uses the equivalent native backend action.
await page.addInitScript(() => {
const notificationLog: Array<{
body: string | null;
title: string;
}> = [];
const notifications: Array<{
body: string | null;
instance: Notification;
title: string;
}> = [];
class DemoNotification extends EventTarget {
static permission: NotificationPermission = "granted";
static async requestPermission() {
return DemoNotification.permission;
}
body: string | null;
onclick: ((event: Event) => void) | null = null;
title: string;
constructor(title: string, options?: NotificationOptions) {
super();
this.title = title;
this.body = options?.body ?? null;
notifications.push({ body: this.body, instance: this, title });
notificationLog.push({ body: this.body, title });
}
close() {}
}
Object.defineProperty(window, "Notification", {
configurable: true,
value: DemoNotification,
});
Object.assign(window, {
__BUZZ_E2E_CLICK_NOTIFICATION__: (index: number) => {
const notification = notifications[index]?.instance;
if (!notification) return false;
const event = new Event("click");
notification.dispatchEvent(event);
notification.onclick?.(event);
return true;
},
__BUZZ_E2E_NOTIFICATIONS__: notificationLog,
});
Object.defineProperty(navigator, "platform", {
configurable: true,
value: "MacIntel",
});
});
await page.goto("/?demo=announcement");
expect(
await page.evaluate(() => ({
permission: Notification.permission,
platform: navigator.platform,
})),
).toEqual({ permission: "granted", platform: "MacIntel" });
await expect(page.getByText("The Hive", { exact: true })).toBeVisible();
await page.getByTestId("channel-flight-path").click();
await expect(page.getByTestId("chat-title")).toHaveText("flight-path");
for (const channel of ["design", "engineering", "marketing"] as const) {
const badge = page.getByTestId(`channel-working-${channel}`);
await expect(badge).toBeVisible();
await expect(badge).toContainText(/^\d+s$/);
}
await expect
.poll(
() =>
page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
return win.__BUZZ_E2E_NOTIFICATIONS__ ?? [];
}),
{ timeout: 12_000 },
)
.toEqual([
{
body: expect.stringContaining("before this prototype gets any bigger"),
title: expect.stringContaining("mentioned you in #engineering"),
},
]);
const clickedNotification = await page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean;
};
return win.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0) ?? false;
});
expect(clickedNotification).toBe(true);
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-timeline")).toContainText(
"it’s already drifting",
);
await expect(page.getByTestId("channel-working-engineering")).toHaveCount(0);
await expect(page.getByTestId("message-timeline")).toContainText(
"Move it to Flutter now",
{ timeout: 5_000 },
);
await expect(page.getByTestId("message-timeline")).toContainText(
"we’re moving the prototype to Flutter",
{ timeout: 7_000 },
);
await expect(page.getByTestId("message-timeline")).toContainText(
"mapping the current React views to Flutter widgets",
{ timeout: 6_000 },
);
await expect(page.getByTestId("message-timeline")).toContainText(
"porting the game loop to Flutter",
{ timeout: 6_000 },
);
await expect(page.getByTestId("message-timeline")).toContainText(
"Building the Flutter widget tree",
{ timeout: 6_000 },
);
await expect(page.getByTestId("message-timeline")).not.toContainText(
"connection hiccup",
);
await expect(
page
.getByTestId("message-row")
.filter({ hasText: "One stack, both platforms" }),
).toHaveCount(1);
});