From 76304ad7123d26983277eaa3f2d2487d24b9d023 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 16 Jun 2026 20:41:02 -0400 Subject: [PATCH 1/2] feat(desktop): surface managed-agent leadership and cooperative steal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3a emits a `leadership_status` observer frame per window-instance every 5s and handles a `claim_leadership` control frame. The desktop had no consumer. This adds the owner-side surface: a per-agent leader badge and a per-instance "Make leader" steal action. The frames already land in `eventsByAgent` via the owner-wide observer subscription, so leadership is a cached derivation rather than a new store. `getAgentLeadership` stays a stable map lookup (required by `useSyncExternalStore`); the `leadershipByAgent` array is rebuilt only when a leadership frame appends. Staleness stays out of the store — the row filters against a 5s clock so a crashed leader's badge drops within 15s without a new frame. The steal ack is non-authoritative: the UI converges off the stream, never optimistically flipping the badge. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/agents/observerRelayStore.ts | 30 +++ .../features/agents/ui/ManagedAgentRow.tsx | 111 +++++++++- desktop/src/features/agents/ui/agentUi.ts | 12 + .../agents/ui/leadershipHelpers.test.mjs | 205 ++++++++++++++++++ .../features/agents/ui/leadershipHelpers.ts | 102 +++++++++ .../features/agents/ui/useObserverEvents.ts | 14 ++ desktop/src/shared/api/agentControl.ts | 15 ++ 7 files changed, 482 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/agents/ui/leadershipHelpers.test.mjs create mode 100644 desktop/src/features/agents/ui/leadershipHelpers.ts diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index ee8483dc8..426b564a2 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -16,6 +16,11 @@ import { createEmptyTranscriptState, processTranscriptEvent, } from "./ui/agentSessionTranscript"; +import { + type InstanceLeadership, + LEADERSHIP_EVENT_KIND, + buildLeadership, +} from "./ui/leadershipHelpers"; const MAX_OBSERVER_EVENTS = 800; @@ -32,11 +37,13 @@ const IDLE_SNAPSHOT: ObserverSnapshot = { }; const EMPTY_TRANSCRIPT: TranscriptItem[] = []; +const EMPTY_LEADERSHIP: InstanceLeadership[] = []; const listeners = new Set<() => void>(); const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +const leadershipByAgent = new Map(); // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). @@ -109,6 +116,14 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { transcriptByAgent.set(key, buildTranscriptState(final)); } + // Rebuild the cached leadership array only when a leadership frame lands, so + // `getAgentLeadership` stays a stable map lookup (referential stability is + // required by `useSyncExternalStore`). The rebuild walks the trimmed window, + // so instances whose latest frame aged out are pruned automatically. + if (event.kind === LEADERSHIP_EVENT_KIND) { + leadershipByAgent.set(key, buildLeadership(final)); + } + // Invalidate cached snapshot for this agent invalidateSnapshot(key); @@ -272,6 +287,20 @@ export function getAgentTranscript( return state?.items ?? EMPTY_TRANSCRIPT; } +export type { InstanceLeadership }; + +export function getAgentLeadership( + agentPubkey?: string | null, + enabled?: boolean, +): InstanceLeadership[] { + if (!enabled || !agentPubkey) { + return EMPTY_LEADERSHIP; + } + return ( + leadershipByAgent.get(normalizePubkey(agentPubkey)) ?? EMPTY_LEADERSHIP + ); +} + export function useManagedAgentObserverBridge( agents: readonly Pick[], ) { @@ -308,6 +337,7 @@ export function resetAgentObserverStore() { eventsByAgent.clear(); transcriptByAgent.clear(); snapshotByAgent.clear(); + leadershipByAgent.clear(); knownAgentPubkeys.clear(); connectionState = "idle"; errorMessage = null; diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 3c7310658..c623e8764 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, Clipboard, + Crown, Ellipsis, FileText, Pencil, @@ -33,13 +34,23 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { EditAgentDialog } from "./EditAgentDialog"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { ModelPicker } from "./ModelPicker"; -import { truncatePubkey } from "./agentUi"; +import { truncateInstanceId, truncatePubkey } from "./agentUi"; +import { useAgentLeadership } from "./useObserverEvents"; +import { + type InstanceLeadership, + filterStaleInstances, + selectFreshestLeader, +} from "./leadershipHelpers"; +import { claimManagedAgentLeadership } from "@/shared/api/agentControl"; export function ManagedAgentRow({ agent, @@ -115,6 +126,21 @@ export function ManagedAgentRow({ // crash. Generic exits stay verbatim so we don't lie about other failures. const friendlyError = friendlyAgentLastError(agent.lastError); + // Leadership frames flow into the owner-wide observer store regardless of + // session-panel state, so this is enabled on row visibility (gated only on a + // pubkey). The 5s clock drives stale eviction without a new frame arriving — + // a crashed leader's last frame ages out and the badge drops within 15s. + const leadership = useAgentLeadership(true, agent.pubkey); + const leadershipNow = useNow(5000); + const liveInstances = React.useMemo( + () => filterStaleInstances(leadership, leadershipNow), + [leadership, leadershipNow], + ); + const leaderInstanceId = React.useMemo( + () => selectFreshestLeader(liveInstances)?.instanceId ?? null, + [liveInstances], + ); + return (
onSelectLogAgent(pubkey)} @@ -335,6 +365,7 @@ function WorkingBadge({ function StatusBlock({ friendlyError, isWorking, + leaderInstanceId, presenceLoaded, presenceStatus, processDetail, @@ -342,6 +373,7 @@ function StatusBlock({ }: { friendlyError: ReturnType; isWorking: boolean; + leaderInstanceId: string | null; presenceLoaded: boolean; presenceStatus: PresenceStatus | undefined; processDetail: string; @@ -352,12 +384,20 @@ function StatusBlock({

Status

- +
+ + {leaderInstanceId ? ( + + + Leader + + ) : null} +

{processDetail}

{friendlyError ? (

void; onDelete: (pubkey: string) => void; onOpenLogs: (pubkey: string) => void; @@ -423,6 +467,8 @@ function AgentActionsMenu({ onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; }) { const [editOpen, setEditOpen] = React.useState(false); + // Nothing to steal unless at least two instances are racing. + const showLeadershipSubmenu = instances.length > 1; return ( <> @@ -522,6 +568,57 @@ function AgentActionsMenu({ ) : null} + {showLeadershipSubmenu ? ( + + + + Leadership + + + {instances.map((instance) => { + const isLeader = instance.instanceId === leaderInstanceId; + return ( + { + try { + await claimManagedAgentLeadership( + agent.pubkey, + instance.instanceId, + ); + toast.success( + `Leadership request sent to ${agent.name}.`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Failed to send leadership request to ${agent.name}.`, + ); + } + }} + > + {isLeader ? ( + + ) : ( + + )} + + {truncateInstanceId(instance.instanceId)} + + + {isLeader + ? "Leader" + : `${formatElapsed(Date.now() - instance.lastSeen)} ago`} + + + ); + })} + + + ) : null} + new Date(epochMs).toISOString(); + +function leadershipEvent({ seq, instanceId, isLeader, at, kind, payload }) { + return { + seq, + timestamp: iso(at), + kind: kind ?? "leadership_status", + agentIndex: null, + channelId: null, + sessionId: null, + turnId: null, + payload: payload ?? { type: "leadership_status", instanceId, isLeader }, + }; +} + +// --- parseLeadershipPayload --- + +test("parseLeadershipPayload accepts a well-formed payload", () => { + const result = parseLeadershipPayload({ + type: "leadership_status", + instanceId: "123-456", + isLeader: true, + }); + assert.deepEqual(result, { instanceId: "123-456", isLeader: true }); +}); + +test("parseLeadershipPayload rejects non-object payloads", () => { + for (const bad of [null, undefined, "string", 42, true, []]) { + // Arrays are objects but lack the required string/boolean fields, so they + // must also be rejected. + assert.equal(parseLeadershipPayload(bad), null); + } +}); + +test("parseLeadershipPayload rejects a missing or non-string instanceId", () => { + assert.equal(parseLeadershipPayload({ isLeader: true }), null); + assert.equal(parseLeadershipPayload({ instanceId: 5, isLeader: true }), null); +}); + +test("parseLeadershipPayload rejects a non-boolean isLeader", () => { + assert.equal( + parseLeadershipPayload({ instanceId: "a", isLeader: "yes" }), + null, + ); + assert.equal(parseLeadershipPayload({ instanceId: "a" }), null); +}); + +// --- buildLeadership --- + +test("buildLeadership keeps the latest frame per instanceId", () => { + const events = [ + leadershipEvent({ seq: 1, instanceId: "A", isLeader: true, at: 1000 }), + leadershipEvent({ seq: 2, instanceId: "B", isLeader: false, at: 1500 }), + leadershipEvent({ seq: 3, instanceId: "A", isLeader: false, at: 2000 }), + ]; + const result = buildLeadership(events); + assert.equal(result.length, 2); + const a = result.find((i) => i.instanceId === "A"); + assert.deepEqual(a, { instanceId: "A", isLeader: false, lastSeen: 2000 }); +}); + +test("buildLeadership ignores non-leadership events", () => { + const events = [ + leadershipEvent({ seq: 1, kind: "turn_started", payload: {}, at: 500 }), + leadershipEvent({ seq: 2, instanceId: "A", isLeader: true, at: 1000 }), + ]; + const result = buildLeadership(events); + assert.deepEqual(result, [ + { instanceId: "A", isLeader: true, lastSeen: 1000 }, + ]); +}); + +test("buildLeadership drops frames that fail the payload guard", () => { + const events = [ + leadershipEvent({ + seq: 1, + payload: { instanceId: 5, isLeader: true }, + at: 1000, + }), + leadershipEvent({ seq: 2, instanceId: "A", isLeader: true, at: 1500 }), + ]; + const result = buildLeadership(events); + assert.deepEqual(result, [ + { instanceId: "A", isLeader: true, lastSeen: 1500 }, + ]); +}); + +test("buildLeadership drops frames with an unparseable timestamp", () => { + const bad = leadershipEvent({ + seq: 1, + instanceId: "A", + isLeader: true, + at: 1000, + }); + bad.timestamp = "not-a-date"; + const good = leadershipEvent({ + seq: 2, + instanceId: "B", + isLeader: false, + at: 1500, + }); + const result = buildLeadership([bad, good]); + assert.deepEqual(result, [ + { instanceId: "B", isLeader: false, lastSeen: 1500 }, + ]); +}); + +test("buildLeadership returns an empty array for no leadership frames", () => { + assert.deepEqual(buildLeadership([]), []); +}); + +test("buildLeadership prunes a zombie instance whose frame aged out of the window", () => { + // Simulates the trimmed event window: the dead instance's frame is gone, so + // only the survivor's frame remains in the input. The reduction therefore + // never re-surfaces the zombie instanceId. + const events = [ + leadershipEvent({ + seq: 9, + instanceId: "survivor", + isLeader: true, + at: 5000, + }), + ]; + const result = buildLeadership(events); + assert.deepEqual( + result.map((i) => i.instanceId), + ["survivor"], + ); +}); + +// --- filterStaleInstances --- + +test("filterStaleInstances drops instances past the stale threshold", () => { + const now = 100_000; + const fresh = { instanceId: "fresh", isLeader: true, lastSeen: now - 1000 }; + const stale = { + instanceId: "stale", + isLeader: false, + lastSeen: now - LEADERSHIP_STALE_MS - 1, + }; + const result = filterStaleInstances([fresh, stale], now); + assert.deepEqual(result, [fresh]); +}); + +test("filterStaleInstances keeps an instance exactly at the threshold", () => { + const now = 100_000; + const boundary = { + instanceId: "boundary", + isLeader: true, + lastSeen: now - LEADERSHIP_STALE_MS, + }; + assert.deepEqual(filterStaleInstances([boundary], now), [boundary]); +}); + +test("filterStaleInstances treats a NaN lastSeen as stale", () => { + const now = 100_000; + const nan = { instanceId: "nan", isLeader: true, lastSeen: Number.NaN }; + // now - NaN === NaN, and `NaN <= threshold` is false, so it is excluded. + assert.deepEqual(filterStaleInstances([nan], now), []); +}); + +// --- selectFreshestLeader --- + +test("selectFreshestLeader returns null when no instance leads", () => { + const instances = [ + { instanceId: "A", isLeader: false, lastSeen: 1000 }, + { instanceId: "B", isLeader: false, lastSeen: 2000 }, + ]; + assert.equal(selectFreshestLeader(instances), null); +}); + +test("selectFreshestLeader picks the freshest among multiple leaders", () => { + // The transient two-leader window after a crash: the dead leader's stale + // isLeader:true and the survivor's fresh one coexist. Freshest wins. + const dead = { instanceId: "dead", isLeader: true, lastSeen: 1000 }; + const survivor = { instanceId: "survivor", isLeader: true, lastSeen: 9000 }; + assert.equal(selectFreshestLeader([dead, survivor]), survivor); +}); + +test("selectFreshestLeader ignores non-leaders even if fresher", () => { + const leader = { instanceId: "leader", isLeader: true, lastSeen: 1000 }; + const followerFresher = { + instanceId: "follower", + isLeader: false, + lastSeen: 9000, + }; + assert.equal(selectFreshestLeader([leader, followerFresher]), leader); +}); + +test("selectFreshestLeader returns null for an empty list", () => { + assert.equal(selectFreshestLeader([]), null); +}); diff --git a/desktop/src/features/agents/ui/leadershipHelpers.ts b/desktop/src/features/agents/ui/leadershipHelpers.ts new file mode 100644 index 000000000..9055d504a --- /dev/null +++ b/desktop/src/features/agents/ui/leadershipHelpers.ts @@ -0,0 +1,102 @@ +import type { ObserverEvent } from "./agentSessionTypes"; + +/** Per-window-instance leadership state derived from `leadership_status` frames. */ +export type InstanceLeadership = { + instanceId: string; + isLeader: boolean; + lastSeen: number; // epoch ms — Date.parse(event.timestamp) +}; + +export const LEADERSHIP_EVENT_KIND = "leadership_status"; + +/** + * An instance is stale once it has missed 3 consecutive 5s emit ticks. A + * surviving instance re-emits within 5s, so 15s tolerates a single dropped + * relay frame without the badge flickering. + */ +export const LEADERSHIP_STALE_MS = 15_000; + +/** + * Narrows the untrusted `unknown` payload of a `leadership_status` frame. + * Harness emits arbitrary JSON (`observer.rs`), so the contents are validated + * here at the boundary; malformed frames are dropped rather than producing + * `undefined`/`NaN` entries. + */ +export function parseLeadershipPayload( + payload: unknown, +): { instanceId: string; isLeader: boolean } | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + const record = payload as Record; + if ( + typeof record.instanceId !== "string" || + typeof record.isLeader !== "boolean" + ) { + return null; + } + return { instanceId: record.instanceId, isLeader: record.isLeader }; +} + +/** + * Reduces an agent's observer events to the latest `leadership_status` frame + * per `instanceId`. `events` must be sorted ascending (the store keeps + * `eventsByAgent` sorted by `compareObserverEvents`), so a simple + * last-write-wins walk in iteration order is correct — no comparator needed. + * + * Instances whose latest frame fell out of the trimmed event window are + * naturally absent, so this also prunes zombie instanceIds. Frames that fail + * the payload guard or carry an unparseable timestamp are dropped. + */ +export function buildLeadership( + events: readonly ObserverEvent[], +): InstanceLeadership[] { + const latestByInstance = new Map(); + for (const event of events) { + if (event.kind !== LEADERSHIP_EVENT_KIND) { + continue; + } + const parsed = parseLeadershipPayload(event.payload); + if (!parsed) { + continue; + } + const lastSeen = Date.parse(event.timestamp); + if (Number.isNaN(lastSeen)) { + continue; + } + latestByInstance.set(parsed.instanceId, { ...parsed, lastSeen }); + } + return [...latestByInstance.values()]; +} + +/** Drops instances whose last frame is older than the stale threshold. */ +export function filterStaleInstances( + instances: readonly InstanceLeadership[], + now: number, +): InstanceLeadership[] { + return instances.filter( + (instance) => now - instance.lastSeen <= LEADERSHIP_STALE_MS, + ); +} + +/** + * The instance to surface as leader: the freshest (`max(lastSeen)`) among + * those reporting `isLeader`. After a leader window crashes, the survivor's + * `isLeader: true` and the dead window's stale `isLeader: true` coexist for up + * to one stale window; picking the freshest converges to the survivor without + * a "contested" UI state. Returns null when no instance currently leads. + */ +export function selectFreshestLeader( + instances: readonly InstanceLeadership[], +): InstanceLeadership | null { + let leader: InstanceLeadership | null = null; + for (const instance of instances) { + if (!instance.isLeader) { + continue; + } + if (!leader || instance.lastSeen > leader.lastSeen) { + leader = instance; + } + } + return leader; +} diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 6631b1677..9771c9a57 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -2,10 +2,12 @@ import * as React from "react"; import { ensureRelayObserverSubscription, + getAgentLeadership, getAgentObserverSnapshot, getAgentTranscript, subscribeAgentObserverStore, } from "@/features/agents/observerRelayStore"; +import type { InstanceLeadership } from "@/features/agents/observerRelayStore"; import type { TranscriptItem } from "./agentSessionTypes"; // Stable subscribe reference shared by all useSyncExternalStore hooks. @@ -45,3 +47,15 @@ export function useAgentTranscript( return React.useSyncExternalStore(subscribeToStore, getSnapshot); } + +export function useAgentLeadership( + enabled: boolean, + agentPubkey?: string | null, +): InstanceLeadership[] { + const getSnapshot = React.useCallback( + () => getAgentLeadership(agentPubkey, enabled), + [agentPubkey, enabled], + ); + + return React.useSyncExternalStore(subscribeToStore, getSnapshot); +} diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 923d93922..46a828102 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -11,3 +11,18 @@ export async function cancelManagedAgentTurn( }); return { status: "sent" }; } + +// Best-effort cooperative-steal request. The harness gates its `control_result` +// ack on a successful lock acquire, so this ack only means "frame sent" — the +// `leadership_status` stream remains the source of truth for who actually +// leads. The UI must not optimistically flip on this return value. +export async function claimManagedAgentLeadership( + pubkey: string, + targetInstanceId: string, +): Promise<{ status: "sent" }> { + await sendAgentObserverControl(pubkey, { + type: "claim_leadership", + targetInstanceId, + }); + return { status: "sent" }; +} From 1a735525a887b960b7f6ca616dfff85ceaf6d73f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 01:42:20 -0400 Subject: [PATCH 2/2] test(desktop): add leadership E2E seed hook + screenshot spec Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + .../src/features/agents/observerRelayStore.ts | 25 +++ desktop/src/testing/e2eBridge.ts | 8 + .../tests/e2e/leadership-screenshots.spec.ts | 188 ++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 desktop/tests/e2e/leadership-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7e22fb77c..0c13b0934 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "**/channel-controls-screenshots.spec.ts", "**/team-management-screenshots.spec.ts", "**/active-turn-screenshots.spec.ts", + "**/leadership-screenshots.spec.ts", "**/profile-active-turn-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 426b564a2..49cd0f413 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -328,6 +328,31 @@ export function useManagedAgentObserverBridge( }, [hasActiveAgent]); } +// Test-only: inject synthetic `leadership_status` frames through the real +// ingest path (`appendAgentEvent`), so the cached-map rebuild the consumer +// reads is exercised — not a fake. Registers the agent as known so the row +// renders. Production ingest (`handleRelayObserverEvent`) is untouched. +export function seedLeadershipForTest( + agentPubkey: string, + instances: readonly { instanceId: string; isLeader: boolean }[], +) { + knownAgentPubkeys.add(normalizePubkey(agentPubkey)); + let seq = Date.now(); + for (const { instanceId, isLeader } of instances) { + seq += 1; + appendAgentEvent(agentPubkey, { + seq, + timestamp: new Date().toISOString(), + kind: LEADERSHIP_EVENT_KIND, + agentIndex: null, + channelId: null, + sessionId: null, + turnId: null, + payload: { instanceId, isLeader }, + }); + } +} + export function resetAgentObserverStore() { generation += 1; const unsubscribe = unsubscribeRelay; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b8afdef20..fa39971c5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8,6 +8,7 @@ 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 { seedLeadershipForTest } from "@/features/agents/observerRelayStore"; import { CUSTOM_EMOJI_SET_D_TAG, KIND_EMOJI_SET, @@ -607,6 +608,10 @@ declare global { channelId: string; turnId: string; }) => void; + __BUZZ_E2E_SEED_LEADERSHIP__?: (input: { + agentPubkey: string; + instances: { instanceId: string; isLeader: boolean }[]; + }) => void; __BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: { clientId: string; contexts: Record; @@ -5940,6 +5945,9 @@ export function maybeInstallE2eTauriMocks() { }, ]); }; + window.__BUZZ_E2E_SEED_LEADERSHIP__ = ({ agentPubkey, instances }) => { + seedLeadershipForTest(agentPubkey, instances); + }; const meshNodeStatus = ( state: "off" | "running", mode: "serve" | "client" | null, diff --git a/desktop/tests/e2e/leadership-screenshots.spec.ts b/desktop/tests/e2e/leadership-screenshots.spec.ts new file mode 100644 index 000000000..42df8aa14 --- /dev/null +++ b/desktop/tests/e2e/leadership-screenshots.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/leadership"; + +// Mock agent pubkeys (distinct from the relay agents seeded by default). +const AGENT_PAUL = "aa".repeat(32); +const AGENT_DUNCAN = "bb".repeat(32); + +type LeadershipInstance = { instanceId: string; isLeader: boolean }; + +async function waitForBridge(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_SEED_LEADERSHIP__?: unknown }) + .__BUZZ_E2E_SEED_LEADERSHIP__ === "function", + null, + { timeout: 10_000 }, + ); +} + +async function openAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForBridge(page); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("unified-agents-groups")).toBeVisible({ + timeout: 10_000, + }); +} + +// The freshest-leader rule selects max(lastSeen), tie-broken by seq. The seed +// hook seeds in array order with a monotonic seq, so list the intended leader +// LAST to make selection deterministic even when timestamps collide at ms. +async function seedLeadership( + page: import("@playwright/test").Page, + agentPubkey: string, + instances: LeadershipInstance[], +) { + await page.evaluate( + ({ pubkey, frames }) => { + const win = window as Window & { + __BUZZ_E2E_SEED_LEADERSHIP__?: (input: { + agentPubkey: string; + instances: { instanceId: string; isLeader: boolean }[]; + }) => void; + }; + win.__BUZZ_E2E_SEED_LEADERSHIP__?.({ + agentPubkey: pubkey, + instances: frames, + }); + }, + { pubkey: agentPubkey, frames: instances }, + ); +} + +const MANAGED_AGENTS = [ + { + pubkey: AGENT_PAUL, + name: "Paul", + status: "running" as const, + channelNames: ["general", "engineering"], + }, + { + pubkey: AGENT_DUNCAN, + name: "Duncan", + status: "running" as const, + channelNames: ["general", "design"], + }, +]; + +async function openLeadershipSubmenu( + page: import("@playwright/test").Page, + agentPubkey: string, +) { + await page.getByTestId(`managed-agent-actions-${agentPubkey}`).click(); + const submenuTrigger = page.getByRole("menuitem", { name: "Leadership" }); + await expect(submenuTrigger).toBeVisible(); + await submenuTrigger.hover(); + // Settle the submenu open animation before capture. + await submenuTrigger.evaluate((el) => + Promise.all( + el + .closest("[data-state]") + ?.getAnimations() + .map((a) => a.finished) ?? [], + ), + ); +} + +test.describe("leadership UI screenshots", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("01 — single instance shows Leader badge, no submenu", async ({ + page, + }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + + await seedLeadership(page, AGENT_PAUL, [ + { instanceId: "4821-1718600000000000", isLeader: true }, + ]); + + const row = page.getByTestId(`managed-agent-${AGENT_PAUL}`); + await expect(row).toContainText("Leader", { timeout: 5_000 }); + + await page.getByTestId(`managed-agent-actions-${AGENT_PAUL}`).click(); + await expect( + page.getByRole("menuitem", { name: "Leadership" }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByTestId("unified-agents-groups").screenshot({ + path: `${SHOTS}/01-single-instance-leader.png`, + }); + }); + + test("02 — multi-instance badge reflects the freshest leader", async ({ + page, + }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + + // Leader seeded last → highest seq → wins the freshest-leader tie-break. + await seedLeadership(page, AGENT_PAUL, [ + { instanceId: "4821-1718600000000000", isLeader: false }, + { instanceId: "5190-1718600100000000", isLeader: false }, + { instanceId: "6033-1718600200000000", isLeader: true }, + ]); + + const row = page.getByTestId(`managed-agent-${AGENT_PAUL}`); + await expect(row).toContainText("Leader", { timeout: 5_000 }); + + await page.getByTestId("unified-agents-groups").screenshot({ + path: `${SHOTS}/02-multi-instance-badge.png`, + }); + }); + + test("03 — leadership submenu lists each instance", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + + await seedLeadership(page, AGENT_PAUL, [ + { instanceId: "4821-1718600000000000", isLeader: false }, + { instanceId: "5190-1718600100000000", isLeader: false }, + { instanceId: "6033-1718600200000000", isLeader: true }, + ]); + + const row = page.getByTestId(`managed-agent-${AGENT_PAUL}`); + await expect(row).toContainText("Leader", { timeout: 5_000 }); + + await openLeadershipSubmenu(page, AGENT_PAUL); + await expect(page.getByRole("menuitem", { name: /Leader/ })).toBeVisible(); + + await page.screenshot({ + path: `${SHOTS}/03-leadership-submenu.png`, + clip: { x: 0, y: 0, width: 1280, height: 720 }, + }); + }); + + test("04 — Make leader action on a non-leader instance", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + + await seedLeadership(page, AGENT_PAUL, [ + { instanceId: "4821-1718600000000000", isLeader: false }, + { instanceId: "5190-1718600100000000", isLeader: false }, + { instanceId: "6033-1718600200000000", isLeader: true }, + ]); + + const row = page.getByTestId(`managed-agent-${AGENT_PAUL}`); + await expect(row).toContainText("Leader", { timeout: 5_000 }); + + await openLeadershipSubmenu(page, AGENT_PAUL); + + // A non-leader instance's item is the enabled cooperative-steal entry point. + const makeLeaderItem = page + .getByRole("menuitem") + .filter({ hasText: "4821" }); + await expect(makeLeaderItem).toBeVisible(); + await makeLeaderItem.hover(); + + await page.screenshot({ + path: `${SHOTS}/04-make-leader-action.png`, + clip: { x: 0, y: 0, width: 1280, height: 720 }, + }); + }); +});