From c80721a9212392a45e196471f03648bf2a93e38a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 16:05:01 -0700 Subject: [PATCH 1/2] test(e2e): add observer-seed harness wiring + populated fixture Adds a test-only seam for seeding live-style observer transcripts into the Activity panel without a running agent or relay: - seedAgentObserverEvents(agentPubkey, events) in observerRelayStore.ts: registers the agent as known, flips the connection to "open", and feeds already-decrypted ObserverEvent[] through the production appendAgentEvent -> processTranscriptEvent pipeline. No mocked decrypt command -- renders through the exact live transcript path. - __BUZZ_E2E_SEED_OBSERVER_FRAMES__ bridge hook in e2eBridge.ts (+ typedef). - desktop/tests/helpers/observerSeedFixture.ts: populated ObserverEvent[] covering lifecycle, user prompt, assistant message, thought, plan, and tool_call/tool_call_update (read_file, shell buzz-send, view_image thumb). --- .../src/features/agents/observerRelayStore.ts | 25 ++ desktop/src/testing/e2eBridge.ts | 12 + desktop/tests/helpers/observerSeedFixture.ts | 231 ++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 desktop/tests/helpers/observerSeedFixture.ts diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index cc8905d0a..4dc5540d3 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -318,6 +318,31 @@ export function useManagedAgentObserverBridge( }, [hasActiveAgent]); } +// E2E-only seam. Injects already-decrypted observer events for an agent +// straight into the store, bypassing the relay subscription and the decrypt +// command. The events flow through the exact same `appendAgentEvent` → +// `processTranscriptEvent` pipeline as production frames, so the rendered +// transcript is faithful to live decrypted output (avatars, grouped prompts, +// tool/shell summaries, view_image thumbnails) without a running agent. The +// agent is registered as known + the connection is flipped to "open" so the +// panel renders the populated state rather than an empty/error placeholder. +// +// Guarded behind an explicit caller (the `__BUZZ_E2E_SEED_OBSERVER_FRAMES__` +// bridge hook) — never reachable from production code paths. +export function seedAgentObserverEvents( + agentPubkey: string, + events: ObserverEvent[], +) { + const key = normalizePubkey(agentPubkey); + knownAgentPubkeys.add(key); + if (connectionState !== "open") { + setConnectionState("open", null); + } + for (const event of events) { + appendAgentEvent(agentPubkey, event); + } +} + export function resetAgentObserverStore() { generation += 1; const unsubscribe = unsubscribeRelay; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8b8e59103..2c61c8b14 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8,6 +8,8 @@ import { relayClient } from "@/shared/api/relayClient"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { RelayEvent } from "@/shared/api/types"; import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore"; +import { seedAgentObserverEvents } from "@/features/agents/observerRelayStore"; +import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; import { CUSTOM_EMOJI_SET_D_TAG, KIND_EMOJI_SET, @@ -611,6 +613,10 @@ declare global { channelId: string; turnId: string; }) => void; + __BUZZ_E2E_SEED_OBSERVER_FRAMES__?: (input: { + agentPubkey: string; + events: ObserverEvent[]; + }) => void; __BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: { clientId: string; contexts: Record; @@ -5947,6 +5953,12 @@ export function maybeInstallE2eTauriMocks() { }, ]); }; + // Seeds populated observer transcript frames for an agent so the Activity + // panel renders live-style states (prompts, assistant messages, tool/shell + // summaries, view_image thumbnails) without a running agent or a relay. + window.__BUZZ_E2E_SEED_OBSERVER_FRAMES__ = ({ agentPubkey, events }) => { + seedAgentObserverEvents(agentPubkey, events); + }; const meshNodeStatus = ( state: "off" | "running", mode: "serve" | "client" | null, diff --git a/desktop/tests/helpers/observerSeedFixture.ts b/desktop/tests/helpers/observerSeedFixture.ts new file mode 100644 index 000000000..b77884de0 --- /dev/null +++ b/desktop/tests/helpers/observerSeedFixture.ts @@ -0,0 +1,231 @@ +// Populated observer-transcript fixture for PR-3 screenshot capture. +// +// Produces a faithful `ObserverEvent[]` that, when fed through the production +// `appendAgentEvent → processTranscriptEvent → getAgentTranscript` pipeline via +// `__BUZZ_E2E_SEED_OBSERVER_FRAMES__`, renders the full range of populated +// Activity-panel states Marge needs to review #1061's UI: +// - lifecycle rows (turn started / session ready) +// - a grouped user prompt with [Buzz event] context sections + author avatar +// - an assistant message bubble +// - a thinking row and a plan row +// - a read tool_call → completed update (read_file) +// - a shell tool rendered as a message-style chat bubble (buzz messages send) +// - a view_image tool_call carrying an inline image thumbnail (mediaInset) +// +// The event shapes mirror the real ACP wire frames: `acp_write session/prompt` +// and `acp_read session/update` with the sessionUpdate discriminator. See +// agentSessionTranscript.ts / agentSessionTranscriptHelpers.ts for the parser +// these payloads are reverse-engineered from. + +type ObserverEvent = { + seq: number; + timestamp: string; + kind: string; + agentIndex: number | null; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + payload: unknown; +}; + +const SESSION_ID = "sess-pr3-001"; +const TURN_ID = "turn-pr3-001"; + +// A tiny 1x1 transparent PNG data URL — stands in for a view_image thumbnail +// so the mediaInset rendering path lights up without shipping a binary asset. +const SAMPLE_IMAGE_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + +type FixtureOptions = { + channelId: string; + /** hex pubkey of the human whose prompt triggered the turn (drives avatar) */ + authorPubkey: string; +}; + +export function buildPopulatedObserverEvents( + opts: FixtureOptions, +): ObserverEvent[] { + const { channelId, authorPubkey } = opts; + let seq = 0; + const base = Date.parse("2026-06-15T18:00:00.000Z"); + const at = (offsetMs: number) => new Date(base + offsetMs).toISOString(); + + const ev = ( + kind: string, + payload: unknown, + offsetMs: number, + ): ObserverEvent => ({ + seq: (seq += 1), + timestamp: at(offsetMs), + kind, + agentIndex: 0, + channelId, + sessionId: SESSION_ID, + turnId: TURN_ID, + payload, + }); + + const sessionUpdate = (update: Record) => ({ + method: "session/update", + params: { update }, + }); + + return [ + // ── lifecycle ────────────────────────────────────────────────────────── + ev( + "turn_started", + { triggeringEventIds: ["evt-aaaa", "evt-bbbb"] }, + 0, + ), + ev("session_resolved", { isNewSession: true }, 200), + + // ── user prompt with [Buzz event] context (drives grouped prompt + avatar) + ev( + "acp_write", + { + method: "session/prompt", + params: { + prompt: [ + { + text: [ + "[System]", + "You are a helpful Buzz agent.", + "[Buzz event: chat message]", + `From: Marge (hex: ${authorPubkey})`, + "Content: Can you read the config file and post a summary to the channel? Also grab a screenshot.", + ].join("\n"), + }, + ], + }, + }, + 400, + ), + + // ── assistant thinking + message ───────────────────────────────────────── + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "agent_thought_chunk", + messageId: "thought-1", + content: { + text: "I'll read the config, summarize it, post to the channel, then attach a screenshot.", + }, + }), + 600, + ), + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "plan", + content: { + text: "1. Read config.toml\n2. Summarize key settings\n3. Send summary via buzz messages send\n4. Capture + attach a screenshot", + }, + }), + 700, + ), + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg-1", + content: { + text: "On it. Reading the config now, then I'll post a summary.", + }, + }), + 800, + ), + + // ── read_file tool_call → completed (read tone) ────────────────────────── + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-read-1", + title: "Read config.toml", + toolName: "read_file", + status: "executing", + rawInput: { path: "config.toml", limit: 40 }, + }), + 1000, + ), + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-read-1", + status: "completed", + content: { + text: "[server]\nport = 3000\nrelay = \"wss://sprout-oss.stage.blox.sqprod.co\"\n[features]\nobserver = true", + }, + }), + 1200, + ), + + // ── shell tool: buzz messages send (renders as message-style chat bubble) ─ + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-shell-1", + title: "Shell", + toolName: "shell", + status: "executing", + rawInput: { + command: + "buzz messages send --channel a9f57da5 --content 'Config summary: port 3000, observer enabled, relay on stage.'", + }, + }), + 1400, + ), + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-shell-1", + status: "completed", + content: { text: '{"accepted":true,"event_id":"8fc888cb..."}' }, + }), + 1600, + ), + + // ── view_image tool: carries a thumbnail (mediaInset rendering) ─────────── + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-img-1", + title: "View image", + toolName: "view_image", + status: "executing", + rawInput: { source: "screenshot.png" }, + }), + 1800, + ), + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-img-1", + status: "completed", + content: [ + { type: "image", mimeType: "image/png", data: SAMPLE_IMAGE_DATA_URL }, + { text: "Captured the Activity panel screenshot (1280x800)." }, + ], + }), + 2000, + ), + + // ── closing assistant message ──────────────────────────────────────────── + ev( + "acp_read", + sessionUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg-2", + content: { + text: "Done — summary posted and the screenshot is attached above. Anything else?", + }, + }), + 2200, + ), + ]; +} From 2fbb83099df258653c6ba087a8786a31e59d6652 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 16:12:39 -0700 Subject: [PATCH 2/2] test(e2e): observer-seed screenshot spec (light+dark populated capture) Adds the playwright spec that drives the seeded agent-session thread panel and captures populated light (catppuccin-latte) + dark (houston) screenshots for #1061 review. Exports canonical OBSERVER_SEED_AGENT_PUBKEY + observerSeedFrames from the fixture and registers the spec in the smoke project testMatch. --- desktop/playwright.config.ts | 1 + .../e2e/observer-seed-screenshots.spec.ts | 169 ++++++++++++++++++ desktop/tests/helpers/observerSeedFixture.ts | 49 +++-- 3 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 desktop/tests/e2e/observer-seed-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3dd4e6c2f..88d6c7dcb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", "**/relay-connectivity-screenshots.spec.ts", + "**/observer-seed-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/tests/e2e/observer-seed-screenshots.spec.ts b/desktop/tests/e2e/observer-seed-screenshots.spec.ts new file mode 100644 index 000000000..3f9a81c93 --- /dev/null +++ b/desktop/tests/e2e/observer-seed-screenshots.spec.ts @@ -0,0 +1,169 @@ +import { expect, test } from "@playwright/test"; + +import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds"; +import { installMockBridge } from "../helpers/bridge"; +import { + OBSERVER_SEED_AGENT_PUBKEY, + observerSeedFrames, +} from "../helpers/observerSeedFixture"; + +// Channel the seeded agent is a member of (via the managedAgents seed below). +// The agent must be a member of the navigated channel so it classifies as a +// channel-session agent — that's what renders the composer activity trigger. +const SEED_CHANNEL_NAME = "general"; + +// Poll until the mock relay has a live typing-indicator subscription for the +// channel. Without this, the typing event is emitted before the channel +// subscribes and is silently dropped, so the composer trigger never paints. +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, + kind?: number, +) { + await expect + .poll(async () => + page.evaluate( + ({ currentChannelName, kind }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + kind, + }) ?? false, + { currentChannelName: channelName, kind }, + ), + ) + .toBe(true); +} + +const SHOTS = "test-results/observer-seed"; + +// Themes the populated panel is captured against. Values map to the real +// THEME_STORAGE_KEY entries read by ThemeProvider (light = catppuccin-latte, +// dark = houston). +const THEMES = [ + { label: "light", value: "catppuccin-latte" }, + { label: "dark", value: "houston" }, +] as const; + +function asWindow(page: import("@playwright/test").Page) { + return page; +} + +async function waitForBridge(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => + typeof ( + window as Window & { + __BUZZ_E2E_SEED_OBSERVER_FRAMES__?: unknown; + } + ).__BUZZ_E2E_SEED_OBSERVER_FRAMES__ === "function", + null, + { timeout: 10_000 }, + ); +} + +// Drives the app from the channel list into the agent-session thread panel for +// the seeded agent, then injects the populated observer transcript. The agent +// is surfaced in the composer activity bar via a mock typing indicator, which +// is what renders the `bot-activity-composer-*` controls. +async function openSeededAgentSession( + page: import("@playwright/test").Page, + themeValue: string, +) { + // Set the theme before the app boots so the first paint is already themed. + await page.addInitScript((value) => { + window.localStorage.setItem("buzz-theme", value); + }, themeValue); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForBridge(page); + + // Open #general and surface the agent as "typing" so the composer activity + // bar exposes the trigger + per-agent item. Wait for the typing-indicator + // subscription to go live first so the emitted event isn't dropped. + await page.getByTestId(`channel-${SEED_CHANNEL_NAME}`).click(); + await waitForMockLiveSubscription( + page, + SEED_CHANNEL_NAME, + KIND_TYPING_INDICATOR, + ); + await page.evaluate((pubkey) => { + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: { + channelName: string; + pubkey: string; + }) => void; + } + ).__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "general", + pubkey, + }); + }, OBSERVER_SEED_AGENT_PUBKEY); + + await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("bot-activity-composer-trigger").click(); + await page + .getByTestId(`bot-activity-composer-item-${OBSERVER_SEED_AGENT_PUBKEY}`) + .click({ force: true }); + + const panel = page.getByTestId("agent-session-thread-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + + // Inject the already-decrypted observer transcript through the production + // appendAgentEvent -> processTranscriptEvent pipeline. + await page.evaluate( + ({ agentPubkey, events }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_OBSERVER_FRAMES__?: (input: { + agentPubkey: string; + events: unknown[]; + }) => void; + } + ).__BUZZ_E2E_SEED_OBSERVER_FRAMES__?.({ agentPubkey, events }); + }, + { agentPubkey: OBSERVER_SEED_AGENT_PUBKEY, events: observerSeedFrames }, + ); + + return panel; +} + +test.describe("observer-seed populated panel screenshots", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const theme of THEMES) { + test(`populated transcript — ${theme.label}`, async ({ page }) => { + await installMockBridge(asWindow(page), { + managedAgents: [ + { + pubkey: OBSERVER_SEED_AGENT_PUBKEY, + name: "Fizz", + status: "running", + channelNames: ["general"], + }, + ], + }); + + const panel = await openSeededAgentSession(page, theme.value); + + // The seeded transcript renders a user prompt, an assistant message, and + // tool/shell summaries — assert one stable marker before capturing so the + // shot isn't taken mid-render. The compact tool row renders the friendly + // label ("Read file"), not the raw tool name. + await expect(panel).toContainText("Read file", { timeout: 10_000 }); + + await panel.screenshot({ + path: `${SHOTS}/populated-${theme.label}.png`, + }); + }); + } +}); diff --git a/desktop/tests/helpers/observerSeedFixture.ts b/desktop/tests/helpers/observerSeedFixture.ts index b77884de0..7476eba55 100644 --- a/desktop/tests/helpers/observerSeedFixture.ts +++ b/desktop/tests/helpers/observerSeedFixture.ts @@ -31,6 +31,17 @@ type ObserverEvent = { const SESSION_ID = "sess-pr3-001"; const TURN_ID = "turn-pr3-001"; +// Canonical seed identities, mirroring the conventions used by the other +// screenshot specs (active-turn-screenshots.spec.ts): +// - agent pubkey is a deterministic all-`aa` hex string +// - the channel is the well-known mock "general" channel id +export const OBSERVER_SEED_AGENT_PUBKEY = "aa".repeat(32); +export const OBSERVER_SEED_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +// Human author whose prompt triggers the seeded turn (drives the prompt avatar). +const OBSERVER_SEED_AUTHOR_PUBKEY = + "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"; + // A tiny 1x1 transparent PNG data URL — stands in for a view_image thumbnail // so the mediaInset rendering path lights up without shipping a binary asset. const SAMPLE_IMAGE_DATA_URL = @@ -54,16 +65,19 @@ export function buildPopulatedObserverEvents( kind: string, payload: unknown, offsetMs: number, - ): ObserverEvent => ({ - seq: (seq += 1), - timestamp: at(offsetMs), - kind, - agentIndex: 0, - channelId, - sessionId: SESSION_ID, - turnId: TURN_ID, - payload, - }); + ): ObserverEvent => { + seq += 1; + return { + seq, + timestamp: at(offsetMs), + kind, + agentIndex: 0, + channelId, + sessionId: SESSION_ID, + turnId: TURN_ID, + payload, + }; + }; const sessionUpdate = (update: Record) => ({ method: "session/update", @@ -72,11 +86,7 @@ export function buildPopulatedObserverEvents( return [ // ── lifecycle ────────────────────────────────────────────────────────── - ev( - "turn_started", - { triggeringEventIds: ["evt-aaaa", "evt-bbbb"] }, - 0, - ), + ev("turn_started", { triggeringEventIds: ["evt-aaaa", "evt-bbbb"] }, 0), ev("session_resolved", { isNewSession: true }, 200), // ── user prompt with [Buzz event] context (drives grouped prompt + avatar) @@ -155,7 +165,7 @@ export function buildPopulatedObserverEvents( toolCallId: "tool-read-1", status: "completed", content: { - text: "[server]\nport = 3000\nrelay = \"wss://sprout-oss.stage.blox.sqprod.co\"\n[features]\nobserver = true", + text: '[server]\nport = 3000\nrelay = "wss://sprout-oss.stage.blox.sqprod.co"\n[features]\nobserver = true', }, }), 1200, @@ -229,3 +239,10 @@ export function buildPopulatedObserverEvents( ), ]; } + +// Pre-built populated transcript for the canonical seed agent + channel. The +// screenshot spec feeds this straight into `__BUZZ_E2E_SEED_OBSERVER_FRAMES__`. +export const observerSeedFrames = buildPopulatedObserverEvents({ + channelId: OBSERVER_SEED_CHANNEL_ID, + authorPubkey: OBSERVER_SEED_AUTHOR_PUBKEY, +});