mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
d16d405a64
commit
808dabd0c4
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user