diff --git a/desktop/announcementDemoAgentPlugin.ts b/desktop/announcementDemoAgentPlugin.ts index c4b97b96aa..14af71ffa3 100644 --- a/desktop/announcementDemoAgentPlugin.ts +++ b/desktop/announcementDemoAgentPlugin.ts @@ -23,6 +23,8 @@ type ProviderErrorBody = { message?: string; }; +type OpenAiReasoningEffort = "low" | "minimal" | "none"; + type AgentEnvironment = Record; function nonEmptyString(value: unknown) { @@ -198,6 +200,22 @@ function extractOpenAiText(body: unknown) { return text || null; } +export function announcementDemoOpenAiReasoningEffort( + model: string, +): OpenAiReasoningEffort | null { + const normalizedModel = model.trim().toLowerCase(); + if (/^gpt-5\.(?:4|5|6)(?:[.-]|$)/.test(normalizedModel)) { + return "none"; + } + if (/^gpt-5(?:[.-]|$)/.test(normalizedModel)) { + return "minimal"; + } + if (/^o[1-9](?:[.-]|$)/.test(normalizedModel)) { + return "low"; + } + return null; +} + function extractAnthropicText(body: unknown) { if (typeof body !== "object" || body === null) { return null; @@ -215,6 +233,7 @@ function extractAnthropicText(body: unknown) { } async function requestOpenAiResponse(input: AnnouncementDemoAgentRequest) { + const reasoningEffort = announcementDemoOpenAiReasoningEffort(input.model); const response = await fetch("https://api.openai.com/v1/responses", { method: "POST", headers: { @@ -225,7 +244,8 @@ async function requestOpenAiResponse(input: AnnouncementDemoAgentRequest) { model: input.model, instructions: input.systemPrompt, input: input.messages, - max_output_tokens: 350, + max_output_tokens: 2_000, + ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}), store: false, }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -239,6 +259,18 @@ async function requestOpenAiResponse(input: AnnouncementDemoAgentRequest) { const text = extractOpenAiText(body); if (!text) { + const responseDetails = body as { + status?: unknown; + incomplete_details?: { reason?: unknown }; + }; + if ( + responseDetails.status === "incomplete" && + responseDetails.incomplete_details?.reason === "max_output_tokens" + ) { + throw new Error( + "OpenAI ran out of output tokens before producing visible text.", + ); + } throw new Error("OpenAI returned a response without any text."); } return text; diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index aac8950b6c..35df3daa3f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -11,9 +11,11 @@ import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnbo import { ANNOUNCEMENT_DEMO_AGENTS, ANNOUNCEMENT_DEMO_COMMUNITY_NAME, + ANNOUNCEMENT_DEMO_INITIAL_READ_CHANNEL_IDS, ANNOUNCEMENT_DEMO_PEOPLE, ANNOUNCEMENT_DEMO_SECTION_STORE, } from "@/testing/announcementDemoFixtures"; +import { localReadStateKey } from "@/features/channels/readState/readStateFormat"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider"; import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider"; @@ -66,6 +68,8 @@ function configureMockBridgeFromUrl() { return; } + // The native recording build intentionally reuses the browser mock bridge; + // only live model calls leave the webview through the local provider proxy. const e2eWindow = window as E2eWindow; if (isAnnouncementDemo) { e2eWindow.__BUZZ_E2E__ = { @@ -104,10 +108,22 @@ function configureMockBridgeFromUrl() { if (isAnnouncementDemo) { const relayStorageScope = encodeURIComponent(mockRelayWsUrl); + const initialReadAt = new Date().toISOString(); window.localStorage.setItem( `${CHANNEL_SECTIONS_STORAGE_KEY_PREFIX}:${E2E_DEFAULT_PUBKEY}:${relayStorageScope}`, JSON.stringify(ANNOUNCEMENT_DEMO_SECTION_STORE), ); + window.localStorage.setItem( + localReadStateKey(E2E_DEFAULT_PUBKEY), + JSON.stringify( + Object.fromEntries( + ANNOUNCEMENT_DEMO_INITIAL_READ_CHANNEL_IDS.map((channelId) => [ + channelId, + initialReadAt, + ]), + ), + ), + ); window.localStorage.setItem( `${SELF_PROFILE_STORAGE_KEY_PREFIX}:${mockRelayWsUrl}:${E2E_DEFAULT_PUBKEY}`, JSON.stringify({ diff --git a/desktop/src/testing/announcementDemoAgentPlugin.test.mjs b/desktop/src/testing/announcementDemoAgentPlugin.test.mjs index 73603dd6e3..7eb53f6b4d 100644 --- a/desktop/src/testing/announcementDemoAgentPlugin.test.mjs +++ b/desktop/src/testing/announcementDemoAgentPlugin.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveAnnouncementDemoAgentRequest } from "../../announcementDemoAgentPlugin.ts"; +import { + announcementDemoOpenAiReasoningEffort, + resolveAnnouncementDemoAgentRequest, +} from "../../announcementDemoAgentPlugin.ts"; const baseRequest = { provider: null, @@ -53,3 +56,10 @@ test("prefers credentials entered in the app over environment fallbacks", () => assert.equal(request.apiKey, "in-app-key"); assert.equal(request.model, "in-app-model"); }); + +test("uses the smallest supported reasoning effort for short demo replies", () => { + assert.equal(announcementDemoOpenAiReasoningEffort("gpt-5.4-mini"), "none"); + assert.equal(announcementDemoOpenAiReasoningEffort("gpt-5"), "minimal"); + assert.equal(announcementDemoOpenAiReasoningEffort("o3-mini"), "low"); + assert.equal(announcementDemoOpenAiReasoningEffort("gpt-4.1-mini"), null); +}); diff --git a/desktop/src/testing/announcementDemoFixtures.ts b/desktop/src/testing/announcementDemoFixtures.ts index 714ebb9452..3ba6b552f9 100644 --- a/desktop/src/testing/announcementDemoFixtures.ts +++ b/desktop/src/testing/announcementDemoFixtures.ts @@ -25,7 +25,13 @@ export const ANNOUNCEMENT_DEMO_AGENTS = [ avatarUrl: "/demo/agents/fizz.png", systemPrompt: "You are Fizz, an energetic maker who turns ideas into action. Be upbeat, practical, and decisive. Help users plan, create, solve problems, and finish work. Add occasional bee wordplay or πŸβœ¨β€”keep it charming, never distracting.", - channelNames: ["flight-path", "design", "marketing", "queen-bee-launch"], + channelNames: [ + "flight-path", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ], }, { pubkey: "71".repeat(32), @@ -33,7 +39,13 @@ export const ANNOUNCEMENT_DEMO_AGENTS = [ avatarUrl: "/demo/agents/honey.png", systemPrompt: "You are Honey, a warm and thoughtful communicator. Help users write clearly, organize ideas, brainstorm, summarize, and prepare for conversations. Be kind, creative, and concise. Add occasional bee wordplay or πŸ―πŸβ€”keep it sweet, never excessive.", - channelNames: ["flight-path", "design", "marketing", "queen-bee-launch"], + channelNames: [ + "flight-path", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ], }, { pubkey: "72".repeat(32), @@ -41,47 +53,104 @@ export const ANNOUNCEMENT_DEMO_AGENTS = [ avatarUrl: "/demo/agents/bumble.png", systemPrompt: "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or πŸπŸ”Žβ€”keep it playful, never chaotic.", - channelNames: ["flight-path", "design", "marketing", "queen-bee-launch"], + channelNames: [ + "flight-path", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ], }, ] as const; export type AnnouncementDemoAgentName = (typeof ANNOUNCEMENT_DEMO_AGENTS)[number]["name"]; -export const ANNOUNCEMENT_DEMO_LIVE_CONVERSATION = { - channelName: "flight-path", - steps: [ - { - delayMs: 1_800, +export const ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS = [ + { + channelName: "flight-path", + agentOrder: ["Fizz", "Honey", "Bumble"], + humanClose: { + delayMs: 1_000, + author: "producer", + content: "That’s the version. I can cut to that β€” nice swarm work 🐝", + }, + steps: [ + { + delayMs: 2_400, + author: "engineer", + content: + "Small thing: the desktop-to-mobile handoff still feels a little fast.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_800, + author: "designer", + content: "Yeah β€” I want one extra beat on the sent message.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_600, + author: "producer", + content: "That would give the camera somewhere to land too.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_800, + author: "engineer", + content: + "@Fizz can you turn that into a clean three-beat capture plan?", + agentMentions: ["Fizz"], + startsAgentChain: true, + }, + ], + }, + { + channelName: "engineering", + agentOrder: ["Bumble", "Fizz", "Honey"], + humanClose: { + delayMs: 1_100, author: "engineer", content: - "Small thing: the desktop-to-mobile handoff still feels a little fast.", - agentMentions: [], - startsAgentChain: false, + "Perfect. That gives me the patch and the verification path β€” I’m on it.", }, - { - delayMs: 2_200, - author: "designer", - content: "Yeah β€” I want one extra beat on the sent message.", - agentMentions: [], - startsAgentChain: false, - }, - { - delayMs: 2_000, - author: "producer", - content: "That would give the camera somewhere to land too.", - agentMentions: [], - startsAgentChain: false, - }, - { - delayMs: 2_200, - author: "engineer", - content: "@Fizz can you turn that into a clean three-beat capture plan?", - agentMentions: ["Fizz"], - startsAgentChain: true, - }, - ], -} as const; + steps: [ + { + delayMs: 2_600, + author: "qa", + content: + "I can still reproduce one duplicate message after waking the laptop.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 3_000, + author: "engineer", + content: "Only on the first reconnect?", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 2_800, + author: "qa", + content: "Yep. One duplicate, then the subscription settles.", + agentMentions: [], + startsAgentChain: false, + }, + { + delayMs: 3_000, + author: "engineer", + content: + "@Bumble can you trace the likely path and pull Fizz and Honey into a fix plan?", + agentMentions: ["Bumble"], + startsAgentChain: true, + }, + ], + }, +] as const; export const ANNOUNCEMENT_DEMO_PEOPLE = { viewer: { @@ -220,6 +289,25 @@ export const ANNOUNCEMENT_DEMO_CHANNELS = [ ["producer", "member"], ], }, + { + id: "4e91b4d2-8207-4d63-9b1a-3c72a8f01142", + name: "engineering", + channelType: "stream", + visibility: "open", + description: "Desktop, relay, and infrastructure engineering", + topic: "Reconnect reliability and the next release candidate", + purpose: "Debug together, review changes, and keep the build healthy.", + createdBy: "engineer", + lastMessageMinutesAgo: 5, + memberRoles: [ + ["engineer", "owner"], + ["viewer", "admin"], + ["qa", "member"], + ["data", "member"], + ["support", "member"], + ["designer", "member"], + ], + }, { id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e", name: "design", @@ -357,6 +445,18 @@ export const ANNOUNCEMENT_DEMO_DMS = [ }, ] as const; +// Start the recording workspace with a believable mix of read and unread +// conversations. Channels omitted from this list retain their unread state. +export const ANNOUNCEMENT_DEMO_INITIAL_READ_CHANNEL_IDS = [ + "9dae0116-799b-5071-a0a8-fdd30a91a35d", // general + "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", // flight-path + "94a444a4-c0a3-5966-ab05-530c6ddc2301", // mobile + "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", // product-ideas + "1be1dcdb-4c31-5a8c-81de-ac102552ca10", // launch-notes + "7eb9f239-9393-50b0-bd76-d85eef0511c7", // Jordan Brooks DM + "d1ec7000-d000-4000-8000-000000000001", // Priya Shah DM +] as const; + export const ANNOUNCEMENT_DEMO_SECTION_STORE = { version: 1, sections: [ @@ -373,6 +473,7 @@ export const ANNOUNCEMENT_DEMO_SECTION_STORE = { "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50": "the-hive", "9dae0116-799b-5071-a0a8-fdd30a91a35d": "the-hive", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9": "product-garden", + "4e91b4d2-8207-4d63-9b1a-3c72a8f01142": "product-garden", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e": "product-garden", "94a444a4-c0a3-5966-ab05-530c6ddc2301": "product-garden", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11": "product-garden", @@ -798,6 +899,142 @@ export const ANNOUNCEMENT_DEMO_MESSAGES: Record< reactions: [{ emoji: "🎬", authors: ["producer", "marketing"] }], }, ], + "4e91b4d2-8207-4d63-9b1a-3c72a8f01142": [ + { + id: "announcement-demo-engineering-old-1", + author: "engineer", + minutesAgo: 6_240, + content: + "I moved reconnect ownership into the subscription manager. Less state crossing the bridge now.", + }, + { + id: "announcement-demo-engineering-old-2", + author: "qa", + minutesAgo: 6_231, + content: "Nice. I’ll run the sleep / wake matrix against it.", + }, + { + id: "announcement-demo-engineering-old-3", + author: "qa", + minutesAgo: 6_198, + content: "First ten loops are clean.", + reactions: [{ emoji: "βœ…", authors: ["engineer", "viewer"] }], + }, + { + id: "announcement-demo-engineering-old-4", + author: "engineer", + minutesAgo: 6_194, + content: "love to hear it", + }, + { + id: "announcement-demo-engineering-old-5", + author: "data", + minutesAgo: 4_330, + content: "Reconnect p95 dropped from 2.8s to 1.1s on the canary cohort.", + reactions: [{ emoji: "⚑", authors: ["engineer", "qa"] }], + }, + { + id: "announcement-demo-engineering-old-6", + author: "engineer", + minutesAgo: 4_318, + content: "That lines up with removing the second backoff window.", + }, + { + id: "announcement-demo-engineering-old-7", + author: "support", + minutesAgo: 2_915, + content: "Two preview teams reported a duplicated message after sleep.", + }, + { + id: "announcement-demo-engineering-old-8", + author: "engineer", + minutesAgo: 2_910, + content: "Same OS version?", + }, + { + id: "announcement-demo-engineering-old-9", + author: "support", + minutesAgo: 2_904, + content: "Both on macOS 15. One Intel, one Apple silicon.", + }, + { + id: "announcement-demo-engineering-old-10", + author: "engineer", + minutesAgo: 1_860, + content: + "Reproduced. The old generation can deliver once after the new one starts.", + }, + { + id: "announcement-demo-engineering-pr", + author: "engineer", + minutesAgo: 1_842, + content: + "Guard is up for review: https://github.com/block/buzz/pull/2037", + reactions: [{ emoji: "πŸ‘€", authors: ["qa", "viewer"] }], + }, + { + id: "announcement-demo-engineering-old-11", + author: "qa", + minutesAgo: 1_831, + content: "Pulling it now.", + }, + { + id: "announcement-demo-engineering-old-12", + author: "qa", + minutesAgo: 1_804, + content: "Twenty reconnects, no duplicates.", + reactions: [{ emoji: "πŸ™Œ", authors: ["engineer", "support"] }], + }, + { + id: "announcement-demo-engineering-1", + author: "engineer", + minutesAgo: 94, + content: "Tightened the generation guard this morning.", + }, + { + id: "announcement-demo-engineering-2", + author: "engineer", + minutesAgo: 92, + content: "```ts\nif (generation !== activeGeneration) return;\n```", + }, + { + id: "announcement-demo-engineering-3", + author: "qa", + minutesAgo: 76, + content: "Manual reconnect and offline recovery both look good.", + }, + { + id: "announcement-demo-engineering-4", + author: "data", + minutesAgo: 61, + content: + "Telemetry is clean too β€” no doubled fan-out in the latest sample.", + }, + { + id: "announcement-demo-engineering-linear", + author: "engineer", + minutesAgo: 43, + content: + "I put the remaining soak checklist in [BUZ-519 Β· Reconnect reliability](https://linear.app/honeycomb/issue/BUZ-519/reconnect-reliability).", + reactions: [{ emoji: "βœ…", authors: ["qa"] }], + }, + { + id: "announcement-demo-engineering-5", + author: "support", + minutesAgo: 22, + content: "Customer replay passed on both affected machines.", + }, + { + id: "announcement-demo-engineering-6", + author: "engineer", + minutesAgo: 5, + content: "Release build is green. I’m doing one last sleep / wake pass.", + reactions: [ + { emoji: "πŸš€", authors: ["qa", "viewer"] }, + { emoji: "πŸ™", authors: ["support"] }, + ], + }, + ], "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e": [ { id: "announcement-demo-design-old-1", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 38232fef9b..60a197274f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -53,7 +53,7 @@ import { ANNOUNCEMENT_DEMO_AGENTS, ANNOUNCEMENT_DEMO_CHANNELS, ANNOUNCEMENT_DEMO_DMS, - ANNOUNCEMENT_DEMO_LIVE_CONVERSATION, + ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS, ANNOUNCEMENT_DEMO_MESSAGES, ANNOUNCEMENT_DEMO_PEOPLE, ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS, @@ -2460,6 +2460,32 @@ const mockChannels: MockChannel[] = [ createMockMember(BOB_PUBKEY, "member", 900), ], }), + createMockChannel({ + id: "4e91b4d2-8207-4d63-9b1a-3c72a8f01142", + name: "engineering-demo", + channel_type: "stream", + visibility: "open", + description: "Desktop, relay, and infrastructure engineering", + topic: "Reconnect reliability and the next release candidate", + purpose: "Debug together, review changes, and keep the build healthy.", + last_message_at: isoMinutesAgo(5), + archived_at: null, + created_by: ALICE_PUBKEY, + topic_set_by: ALICE_PUBKEY, + topic_set_at: isoMinutesAgo(100), + purpose_set_by: ALICE_PUBKEY, + purpose_set_at: isoMinutesAgo(110), + topic_required: false, + max_members: null, + nip29_group_id: null, + created_minutes_ago: 1310, + updated_minutes_ago: 5, + members: [ + createMockMember(ALICE_PUBKEY, "owner", 1310), + createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1170), + createMockMember(BOB_PUBKEY, "member", 890), + ], + }), createMockChannel({ id: "94a444a4-c0a3-5966-ab05-530c6ddc2301", name: "agents", @@ -2721,7 +2747,7 @@ let mockWebsocketSendMutexWedged = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; const pendingAnnouncementDemoAgentResponses = new Set(); -let announcementDemoLiveConversationStarted = false; +const startedAnnouncementDemoLiveConversations = new Set(); let mockGlobalAgentConfig: MockGlobalAgentConfig = { env_vars: {}, provider: null, @@ -4216,6 +4242,32 @@ type AnnouncementDemoAgentReply = { error?: string; }; +function getAnnouncementDemoLiveConversation(channelId: string) { + const channel = mockChannels.find((candidate) => candidate.id === channelId); + return ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS.find( + (conversation) => conversation.channelName === channel?.name, + ); +} + +function getAnnouncementDemoNextAgentName( + channelId: string, + currentAgentPubkey: string, +): AnnouncementDemoAgentName | null { + const currentAgent = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => + normalizePubkey(candidate.pubkey) === normalizePubkey(currentAgentPubkey), + ); + if (!currentAgent) { + return null; + } + + const agentOrder = + getAnnouncementDemoLiveConversation(channelId)?.agentOrder ?? + ANNOUNCEMENT_DEMO_AGENTS.map((agent) => agent.name); + const currentIndex = agentOrder.indexOf(currentAgent.name); + return agentOrder[currentIndex + 1] ?? null; +} + function getAnnouncementDemoProviderConfig( agent: MockManagedAgent, ): AnnouncementDemoProviderConfig { @@ -4285,21 +4337,18 @@ function getAnnouncementDemoAgentTargets(channelId: string, event: RelayEvent) { channel.channel_type === "dm" ? new Set(channel.members.map((member) => normalizePubkey(member.pubkey))) : new Set(); - const sourceAgentIndex = isAgentAuthored - ? ANNOUNCEMENT_DEMO_AGENTS.findIndex( - (candidate) => - normalizePubkey(candidate.pubkey) === normalizePubkey(event.pubkey), - ) - : -1; + const nextAgentName = isAgentAuthored + ? getAnnouncementDemoNextAgentName(channelId, event.pubkey) + : null; return mockManagedAgents.filter((agent) => { const pubkey = normalizePubkey(agent.pubkey); const isActive = agent.status === "running" || agent.status === "deployed"; if (isAgentAuthored) { - const targetAgentIndex = ANNOUNCEMENT_DEMO_AGENTS.findIndex( + const targetDefinition = ANNOUNCEMENT_DEMO_AGENTS.find( (candidate) => normalizePubkey(candidate.pubkey) === pubkey, ); - if (targetAgentIndex !== sourceAgentIndex + 1) { + if (targetDefinition?.name !== nextAgentName) { return false; } } @@ -4310,6 +4359,7 @@ function getAnnouncementDemoAgentTargets(channelId: string, event: RelayEvent) { } function getAnnouncementDemoNextAgent( + channelId: string, sourceEvent: RelayEvent, agent: MockManagedAgent, ) { @@ -4320,11 +4370,13 @@ function getAnnouncementDemoNextAgent( return null; } - const currentIndex = ANNOUNCEMENT_DEMO_AGENTS.findIndex( - (candidate) => - normalizePubkey(candidate.pubkey) === normalizePubkey(agent.pubkey), + const nextAgentName = getAnnouncementDemoNextAgentName( + channelId, + agent.pubkey, + ); + const nextDefinition = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => candidate.name === nextAgentName, ); - const nextDefinition = ANNOUNCEMENT_DEMO_AGENTS[currentIndex + 1]; if (!nextDefinition) { return null; } @@ -4374,7 +4426,7 @@ function getAnnouncementDemoSystemPrompt( channel?.channel_type === "dm" ? "a direct message" : `#${channel?.name ?? "a team channel"}`; - const nextAgent = getAnnouncementDemoNextAgent(sourceEvent, agent); + const nextAgent = getAnnouncementDemoNextAgent(channelId, sourceEvent, agent); const isAgentChainEvent = sourceEvent.tags.some( (tag) => tag[0] === ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, ); @@ -4383,7 +4435,7 @@ function getAnnouncementDemoSystemPrompt( : isAgentChainEvent ? " This is the final turn in a short agent collaboration: respond directly to the previous agent, share one useful check or refinement, and close the loop without handing off again." : ""; - return `${basePrompt}\n\nYou are replying in ${location}. Use the conversation context, sound like a real colleague, and do not prefix the reply with your name.${collaborationInstruction}`; + return `${basePrompt}\n\nYou are replying in ${location}. Use the conversation context, sound like a real colleague, and do not prefix the reply with your name. Keep the reply to one to three short sentences with no lists or code blocks.${collaborationInstruction}`; } async function requestAnnouncementDemoAgentReply( @@ -4392,6 +4444,8 @@ async function requestAnnouncementDemoAgentReply( sourceEvent: RelayEvent, ) { 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 response = await fetch("/__announcement-demo/agent-response", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -4437,7 +4491,7 @@ function publishAnnouncementDemoAgentReply( (tag) => tag[0] === ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, ); const nextAgent = continueAgentChain - ? getAnnouncementDemoNextAgent(sourceEvent, agent) + ? getAnnouncementDemoNextAgent(channelId, sourceEvent, agent) : null; const mentionPubkeys = [ sourceEvent.pubkey, @@ -4472,16 +4526,24 @@ function publishAnnouncementDemoAgentReply( ANNOUNCEMENT_DEMO_AGENT_HANDOFF_DELAY_MS, ); } else if (isAgentChainEvent && continueAgentChain) { - const producerPubkey = getAnnouncementDemoPersonPubkey("producer"); + 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 closingAuthorPubkey = getAnnouncementDemoPersonPubkey( + humanClose.author, + ); window.setTimeout(() => { - emitMockTypingIndicator(channelId, producerPubkey); + emitMockTypingIndicator(channelId, closingAuthorPubkey); window.setTimeout( () => emitMockChannelMessage( channelId, - "That’s the version. I can cut to that β€” nice swarm work 🐝", + humanClose.content, null, - producerPubkey, + closingAuthorPubkey, 9, undefined, [["announcement-demo-live", "1"]], @@ -4490,7 +4552,7 @@ function publishAnnouncementDemoAgentReply( ), 650, ); - }, ANNOUNCEMENT_DEMO_HUMAN_CLOSE_DELAY_MS); + }, humanClose.delayMs); } } @@ -4501,7 +4563,6 @@ const ANNOUNCEMENT_DEMO_AGENT_WORKING_DELAY_MS = 350; const ANNOUNCEMENT_DEMO_AGENT_MIN_TURN_MS = 1_800; const ANNOUNCEMENT_DEMO_AGENT_REACTION_CLEANUP_DELAY_MS = 2_500; const ANNOUNCEMENT_DEMO_AGENT_HANDOFF_DELAY_MS = 700; -const ANNOUNCEMENT_DEMO_HUMAN_CLOSE_DELAY_MS = 700; function publishAnnouncementDemoAgentReaction( channelId: string, @@ -4635,25 +4696,25 @@ function queueAnnouncementDemoAgentResponses( function maybeStartAnnouncementDemoLiveConversation(channelId: string) { if ( - announcementDemoLiveConversationStarted || + startedAnnouncementDemoLiveConversations.has(channelId) || !getConfig()?.mock?.announcementDemo ) { return; } - const channel = mockChannels.find((candidate) => candidate.id === channelId); - if (channel?.name !== ANNOUNCEMENT_DEMO_LIVE_CONVERSATION.channelName) { + const conversation = getAnnouncementDemoLiveConversation(channelId); + if (!conversation) { return; } - announcementDemoLiveConversationStarted = true; + startedAnnouncementDemoLiveConversations.add(channelId); let elapsedMs = 0; - for (const step of ANNOUNCEMENT_DEMO_LIVE_CONVERSATION.steps) { + for (const step of conversation.steps) { elapsedMs += step.delayMs; const authorPubkey = getAnnouncementDemoPersonPubkey(step.author); window.setTimeout( () => emitMockTypingIndicator(channelId, authorPubkey), - Math.max(0, elapsedMs - 850), + Math.max(0, elapsedMs - 1_000), ); window.setTimeout(() => { const agentMentionPubkeys = ( diff --git a/desktop/tests/e2e/announcement-demo.spec.ts b/desktop/tests/e2e/announcement-demo.spec.ts index 8f2058c56a..55b9f1372d 100644 --- a/desktop/tests/e2e/announcement-demo.spec.ts +++ b/desktop/tests/e2e/announcement-demo.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test("announcement demo loads its workspace, people, and projects", async ({ page, }) => { - test.setTimeout(60_000); + test.setTimeout(75_000); const agentReply = "I’d lead with the handoff moment, then land on the shared launch room. That gives the story a clear before-and-after."; const fizzReply = @@ -12,6 +12,14 @@ test("announcement demo loads its workspace, people, and projects", async ({ "Frame it as one thought moving with you: send, surface, arrive together. @Bumble, can you sanity-check the sequence? 🍯"; const bumbleReply = "The sequence tracks. Keep the notification visible for a full beat before the cut, and the handoff will read without narration. πŸπŸ”Ž"; + const engineeringBumbleReply = + "The duplicate points to the previous subscription generation delivering once after reconnect. @Fizz, can you turn that into the smallest safe guard?"; + const engineeringFizzReply = + "I’d reject callbacks whose generation no longer matches the active subscription, then add a sleep-wake regression case. @Honey, can you package the rollout check?"; + const engineeringHoneyReply = + "Ship the guard behind the existing reconnect path, run the twenty-cycle soak, and call out duplicate delivery in the release checklist. That closes the loop cleanly."; + const engineeringHumanClose = + "Perfect. That gives me the patch and the verification path β€” I’m on it."; const humanClose = "That’s the version. I can cut to that β€” nice swarm work 🐝"; const fizzReplyVisible = fizzReply.replace("@Honey", "Honey"); @@ -34,8 +42,14 @@ test("announcement demo loads its workspace, people, and projects", async ({ const isFinalTurn = body.systemPrompt?.includes( "final turn in a short agent collaboration", ); - const text = - body.systemPrompt?.includes("You are Fizz") && isCollaborativeTurn + const isEngineering = body.systemPrompt?.includes("in #engineering"); + const text = isEngineering + ? body.systemPrompt?.includes("You are Bumble") && isCollaborativeTurn + ? engineeringBumbleReply + : body.systemPrompt?.includes("You are Fizz") && isCollaborativeTurn + ? engineeringFizzReply + : engineeringHoneyReply + : body.systemPrompt?.includes("You are Fizz") && isCollaborativeTurn ? fizzReply : body.systemPrompt?.includes("You are Honey") && isCollaborativeTurn ? honeyReply @@ -99,6 +113,54 @@ test("announcement demo loads its workspace, people, and projects", async ({ ).toBeVisible(); await expect(page.getByTestId("channel-DM")).toHaveCount(3); + const unreadChannels = [ + "announcements", + "engineering", + "design", + "marketing", + "queen-bee-launch", + ] as const; + const readChannels = [ + "general", + "flight-path", + "mobile", + "product-ideas", + "launch-notes", + ] as const; + for (const channel of unreadChannels) { + await expect( + page + .getByTestId(`channel-${channel}`) + .locator("[data-testid^='channel-unread-']"), + ).toBeVisible(); + } + for (const channel of readChannels) { + await expect( + page + .getByTestId(`channel-${channel}`) + .locator("[data-testid^='channel-unread-']"), + ).toHaveCount(0); + } + + const mayaDm = page + .getByTestId("channel-DM") + .filter({ hasText: "Maya Chen" }); + const jordanDm = page + .getByTestId("channel-DM") + .filter({ hasText: "Jordan Brooks" }); + const priyaDm = page + .getByTestId("channel-DM") + .filter({ hasText: "Priya Shah" }); + await expect( + mayaDm.locator("xpath=..").getByTestId("channel-unread-DM"), + ).toBeVisible(); + await expect( + jordanDm.locator("xpath=..").getByTestId("channel-unread-DM"), + ).toHaveCount(0); + await expect( + priyaDm.locator("xpath=..").getByTestId("channel-unread-DM"), + ).toHaveCount(0); + await page.getByTestId("channel-flight-path").click(); const channelTimeline = page.getByTestId("message-timeline"); await expect(channelTimeline).toContainText("Marcus Reed"); @@ -210,6 +272,47 @@ test("announcement demo loads its workspace, people, and projects", async ({ await page.getByTestId("send-message").click(); await expect(channelTimeline).toContainText(agentReply, { timeout: 10_000 }); + await page.getByTestId("channel-engineering").click(); + await expect(channelTimeline).toContainText( + "Release build is green. I’m doing one last sleep / wake pass.", + ); + await expect( + channelTimeline.locator('[data-link-preview="github-pull-request"]'), + ).toBeVisible(); + await expect( + channelTimeline.locator('[data-link-preview="linear-issue"]'), + ).toBeVisible(); + await expect(channelTimeline).toContainText( + "I can still reproduce one duplicate message after waking the laptop.", + { timeout: 5_000 }, + ); + await expect(channelTimeline).toContainText("Only on the first reconnect?"); + await expect(channelTimeline).toContainText( + "One duplicate, then the subscription settles.", + ); + await expect(channelTimeline).toContainText( + "Bumble can you trace the likely path and pull Fizz and Honey into a fix plan?", + { timeout: 10_000 }, + ); + await expect(channelTimeline).toContainText( + engineeringBumbleReply.replace("@Fizz", "Fizz"), + { timeout: 5_000 }, + ); + await expect(channelTimeline).toContainText( + engineeringFizzReply.replace("@Honey", "Honey"), + { timeout: 5_000 }, + ); + await expect(channelTimeline).toContainText(engineeringHoneyReply, { + timeout: 5_000, + }); + await expect(channelTimeline).toContainText(engineeringHumanClose, { + timeout: 5_000, + }); + const engineeringAgentRows = page + .getByTestId("message-row") + .filter({ has: page.locator('img[src^="/demo/agents/"]') }); + await expect(engineeringAgentRows).toHaveCount(3); + const populatedChannels = [ ["announcements", "Final smoke pass is clean"], ["general", "Please nobody breathe on main"],