From b01a6a397034ff5c6f1537d9ecbbf47264eca781 Mon Sep 17 00:00:00 2001 From: cid Date: Wed, 23 Sep 2026 16:14:34 -0700 Subject: [PATCH 1/6] feat: add owned local agent actions to profiles Signed-off-by: cid --- docs/profiles.md | 30 +- .../profiles/ProfileAgentActions.test.tsx | 321 ++++++++++++++++++ src/bundled/profiles/ProfileAgentActions.tsx | 124 +++++++ src/bundled/profiles/ProfilePanel.tsx | 11 +- 4 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 src/bundled/profiles/ProfileAgentActions.test.tsx create mode 100644 src/bundled/profiles/ProfileAgentActions.tsx diff --git a/docs/profiles.md b/docs/profiles.md index dfb828311..ca6a6aacb 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -2,8 +2,9 @@ The bundled `buzz.profiles` plugin supplies a profile panel for any public identity, human or agent. It uses the current session's shared profile -directory. Agents retains agent-specific configuration/operations; this slice -adds no ownership/running badge, editor, agent-library read or execution API. +directory. Agents retains agent-specific configuration and its page-local editor. Profiles +can dispatch existing local Start/Stop/Restart commands for an exact managed +identity in the active community; profile metadata and library hints grant no authority. When Agent Activity is enabled and the host supplies conversation context, **View activity** opens its raw panel for this exact identity and originating channel. The Info tab's “Latest activity” card shows up to three recently updated assistant @@ -159,3 +160,28 @@ Before publisher entry, navigation or loss of eligibility stops the write. Once publication begins, leaving the tab cannot undo the request; the session outbox retains its outcome and an unconfirmed result requires checking membership before attempting again. Neither list is a cross-community/global directory. + +## Owned local agent actions + +`ProfileAgentActions` observes the app-owned `AgentControl` injected into Profiles. +It matches the exact public key and canonical active-community scope to one native +ID; namesakes, other-community identities, ambiguous matches and browser-only +profiles get no runtime actions. It adds no controller, relay scan or agent editor. +While mounted it refreshes host evidence every five seconds when visible/ready, +matching Agents; errors stop polling and expose explicit Retry status. Unmount +releases observation, never native execution. + +Start/Restart require ready host evidence, an available runtime and no pending +operation or process transition. Stop uses the controller's existing recovery +policy, including stale evidence and pending launch/credential waits; it is the +intentional exception to disabling pending actions. A pending Stop cannot repeat. +Host failures remain visible with snapshot uncertainty, and retired relay +presentations cannot dispatch commands. The separate runtime child owns badges +and runtime detail; actions do not infer relay readiness. + +Edit ingress is deferred: Agents currently registers no specific editor route; +its editor selection is page-local state. No invented route or second editor is +added. Mounted React regression tests exercise exact dispatch, pending/failure/ +recovery and profile/community lifecycle through the real controller projection +with a synthetic native host. Live process/credential handover and rendered native +acceptance remain attended checks, not established by these tests. diff --git a/src/bundled/profiles/ProfileAgentActions.test.tsx b/src/bundled/profiles/ProfileAgentActions.test.tsx new file mode 100644 index 000000000..66a739814 --- /dev/null +++ b/src/bundled/profiles/ProfileAgentActions.test.tsx @@ -0,0 +1,321 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { + createAgentControl, + type ControlSnapshot, +} from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { profileTarget } from "../../features/profiles/target"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import { createRelaySession } from "../../features/relay/session"; +import { ProfilePanel } from "./ProfilePanel"; + +const disposers: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const dispose of disposers.splice(0)) dispose(); + vi.useRealTimers(); +}); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function setup(native = true) { + const fixture = controlFixture(); + const control = createAgentControl(native ? fixture.host : null); + const owner = createRelaySession(null); + disposers.push(control.dispose, owner.dispose); + const listeners = new Set<() => void>(); + let snapshot: RelaySnapshot = { + status: "ready", + generation: 1, + session: owner.session, + viewer: "cd".repeat(32), + scope: `https://relay.example.test:${"cd".repeat(32)}`, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + retry() {}, + disconnect() {}, + async clearCache() {}, + }; + const panel = (pubkey = fixture.agent.pubkey) => ( + + {}} + /> + + ); + const switchScope = (patch: Partial, notify = true) => { + snapshot = { ...snapshot, ...patch }; + if (notify) for (const listener of listeners) listener(); + }; + return { ...fixture, control, relay, panel, switchScope }; +} + +it("dispatches Stop, Start and Restart to the exact local native ID, never a namesake or another relay", async () => { + const h = setup(); + h.data.agents.unshift( + { ...h.agent, id: "other-relay", relayUrl: "wss://other.test" }, + { ...h.agent, id: "namesake", pubkey: "ef".repeat(32) }, + ); + const user = userEvent.setup(); + render(h.panel()); + await user.click(await screen.findByRole("button", { name: "Stop" })); + await user.click(await screen.findByRole("button", { name: "Start" })); + await user.click(screen.getByRole("button", { name: "Restart" })); + expect(h.calls.filter((call) => call.action !== "snapshot")).toEqual([ + { action: "stop", payload: { id: h.agent.id } }, + { action: "start", payload: { id: h.agent.id } }, + { action: "restart", payload: { id: h.agent.id } }, + ]); +}); + +it.each([ + "public identity", + "other relay", + "duplicate identity", + "browser", + "missing scope", +])("does not expose controls for %s", async (mode) => { + const h = setup(mode !== "browser"); + if (mode === "other relay") h.agent.relayUrl = "wss://other.test"; + if (mode === "duplicate identity") + h.data.agents.push({ ...h.agent, id: "duplicate" }); + if (mode === "missing scope") h.switchScope({ scope: "" }); + const view = render( + h.panel(mode === "public identity" ? "ef".repeat(32) : undefined), + ); + await act(() => h.control.refresh()); + expect( + screen.queryByRole("region", { name: "Local agent actions" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^(Start|Stop|Restart|Edit)$/ }), + ).not.toBeInTheDocument(); + expect(h.calls.every((call) => call.action === "snapshot")).toBe(true); + view.unmount(); +}); + +it("blocks runtime-unavailable and transitioning launches but preserves recovery Stop", async () => { + const h = setup(); + h.data.runtimeAvailable = false; + h.data.runtimeMessage = "Runtime resources are missing."; + render(h.panel()); + expect( + await screen.findByText("Runtime resources are missing."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled(); + h.data.runtimeAvailable = true; + h.agent.status = "starting"; + await act(() => h.control.refresh()); + expect(screen.getByRole("button", { name: "Start" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled(); + expect( + screen.getByText("Waiting for the process transition."), + ).toBeInTheDocument(); +}); + +it("disables duplicate pending commands, reports failure, and requires explicit status recovery", async () => { + const h = setup(); + const pending = deferred(); + const action = vi + .spyOn(h.host, "action") + .mockImplementationOnce(() => pending.promise); + const user = userEvent.setup(); + render(h.panel()); + await user.click(await screen.findByRole("button", { name: "Stop" })); + try { + expect(screen.getByRole("button", { name: "Stop" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled(); + expect( + screen.getByText("Waiting for the host to confirm…"), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Stop" })); + expect(action).toHaveBeenCalledTimes(1); + } finally { + await act(async () => + pending.reject("The host could not stop the process."), + ); + } + expect( + screen.getByText(/The host could not stop the process/), + ).toBeInTheDocument(); + expect( + screen.getByText(/Showing the last host snapshot/), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Retry status" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Restart" })).toBeEnabled(), + ); + await user.click(screen.getByRole("button", { name: "Restart" })); + expect(action).toHaveBeenLastCalledWith(h.agent.id, "restart"); + expect( + within( + screen.getByRole("region", { name: "Local agent actions" }), + ).queryByRole("alert"), + ).not.toBeInTheDocument(); +}); + +it("allows recovery Stop during a pending Start and ignores its late failure", async () => { + const h = setup(); + h.agent.enabled = false; + h.agent.status = "stopped"; + const pending = deferred(); + const action = vi + .spyOn(h.host, "action") + .mockImplementationOnce(() => pending.promise); + const user = userEvent.setup(); + render(h.panel()); + await user.click(await screen.findByRole("button", { name: "Start" })); + try { + expect(screen.getByRole("button", { name: "Start" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Stop" })); + expect(action).toHaveBeenLastCalledWith(h.agent.id, "stop"); + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled(); + } finally { + await act(async () => pending.reject("Retired launch failed.")); + } + expect( + within( + screen.getByRole("region", { name: "Local agent actions" }), + ).queryByRole("alert"), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start" })).toBeEnabled(); +}); + +it("recovers an initial read failure without inferring ownership and stops polling errors", async () => { + const h = setup(); + const read = vi + .spyOn(h.host, "snapshot") + .mockRejectedValueOnce("No host status"); + const user = userEvent.setup(); + render(h.panel()); + await screen.findByRole("button", { name: "Retry status" }); + expect( + screen.queryByRole("button", { name: "Stop" }), + ).not.toBeInTheDocument(); + vi.useFakeTimers(); + await act(() => vi.advanceTimersByTimeAsync(15000)); + expect(read).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + await user.click(screen.getByRole("button", { name: "Retry status" })); + expect(await screen.findByRole("button", { name: "Stop" })).toBeEnabled(); +}); + +it("fences retired relay clicks and tracks profile, community, reconnect and disconnect changes", async () => { + const h = setup(); + const user = userEvent.setup(); + const view = render(h.panel()); + const stop = await screen.findByRole("button", { name: "Stop" }); + // Retire synchronously before React hears the subscription notification. + h.switchScope({ scope: `https://other.test:${"cd".repeat(32)}` }, false); + await user.click(stop); + expect(h.calls.filter((call) => call.action !== "snapshot")).toEqual([]); + act(() => h.switchScope({})); + expect( + screen.queryByRole("button", { name: "Stop" }), + ).not.toBeInTheDocument(); + act(() => + h.switchScope({ + scope: `https://relay.example.test:${"cd".repeat(32)}`, + generation: 2, + }), + ); + expect(await screen.findByRole("button", { name: "Stop" })).toBeEnabled(); + view.rerender(h.panel("ef".repeat(32))); + expect( + screen.queryByRole("button", { name: "Stop" }), + ).not.toBeInTheDocument(); + view.rerender(h.panel()); + expect(await screen.findByRole("button", { name: "Stop" })).toBeEnabled(); + act(() => h.switchScope({ status: "disconnected" })); + expect( + screen.queryByRole("button", { name: "Stop" }), + ).not.toBeInTheDocument(); + expect( + screen.getByText("Connect to a community to view this profile."), + ).toBeInTheDocument(); +}); + +it("does not leak late completion into a replacement identity or dispose native execution on unmount", async () => { + const h = setup(); + const pending = deferred(); + vi.spyOn(h.host, "action").mockImplementationOnce(() => pending.promise); + const read = vi.spyOn(h.host, "snapshot"); + const dispose = vi.spyOn(h.control, "dispose"); + const user = userEvent.setup(); + const view = render(h.panel()); + await user.click(await screen.findByRole("button", { name: "Restart" })); + try { + view.rerender(h.panel("ef".repeat(32))); + expect( + screen.queryByRole("button", { name: "Stop" }), + ).not.toBeInTheDocument(); + } finally { + await act(async () => pending.resolve(structuredClone(h.data))); + } + expect( + screen.queryByRole("region", { name: "Local agent actions" }), + ).not.toBeInTheDocument(); + view.unmount(); + const reads = read.mock.calls.length; + vi.useFakeTimers(); + await act(() => vi.advanceTimersByTimeAsync(15000)); + expect(read).toHaveBeenCalledTimes(reads); + expect(dispose).not.toHaveBeenCalled(); + expect(h.control.snapshot().status).toBe("ready"); +}); + +it("polls only while visible and ready, and releases the timer on unmount", async () => { + vi.useFakeTimers(); + const h = setup(); + const read = vi.spyOn(h.host, "snapshot"); + const visibility = vi.spyOn(document, "visibilityState", "get"); + try { + const view = render(h.panel()); + await act(() => h.control.refresh()); + expect(read).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(5000)); + expect(read).toHaveBeenCalledTimes(2); + visibility.mockReturnValue("hidden"); + await act(() => vi.advanceTimersByTimeAsync(5000)); + expect(read).toHaveBeenCalledTimes(2); + visibility.mockReturnValue("visible"); + view.unmount(); + await act(() => vi.advanceTimersByTimeAsync(10000)); + expect(read).toHaveBeenCalledTimes(2); + } finally { + visibility.mockRestore(); + } +}); diff --git a/src/bundled/profiles/ProfileAgentActions.tsx b/src/bundled/profiles/ProfileAgentActions.tsx new file mode 100644 index 000000000..345765063 --- /dev/null +++ b/src/bundled/profiles/ProfileAgentActions.tsx @@ -0,0 +1,124 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { + canStopAgent, + type AgentAction, + type AgentControl, +} from "../../features/agents/control"; +import { sameCommunityAgents } from "../../features/agents/choices"; +import { useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import { Button } from "../../shared/design-system/ui/Button"; + +/** Observe the app-owned controller; profile metadata never grants control. */ +export function ProfileAgentActions({ + control, + relay, + pubkey, +}: { + control: AgentControl; + relay: RelayData; + pubkey: string; +}) { + const connection = useRelayConnection(relay); + const state = useSyncExternalStore( + control.subscribe, + control.snapshot, + control.snapshot, + ); + useEffect(() => { + void control.refresh(); + const timer = setInterval(() => { + if ( + document.visibilityState !== "hidden" && + control.snapshot().status === "ready" + ) + void control.refresh(); + }, 5000); + return () => clearInterval(timer); + }, [control]); + if (connection.status !== "ready" || !connection.scope) return null; + const matches = sameCommunityAgents( + state.data?.agents ?? [], + connection.scope, + ).filter((agent) => agent.pubkey === pubkey); + // A native ID is actionable only when the identity/destination is unambiguous. + const agent = matches.length === 1 ? matches[0] : undefined; + if (!agent && state.status !== "error") return null; + const transitioning = + agent?.status === "starting" || agent?.status === "stopping"; + const startBlock = + state.status !== "ready" + ? "Refresh status before starting." + : state.busy + ? "Waiting for the current operation." + : !state.data?.runtimeAvailable + ? state.data?.runtimeMessage || + "The bundled agent runtime is unavailable." + : transitioning + ? "Waiting for the process transition." + : null; + const act = (action: AgentAction) => { + // A retired presentation cannot dispatch into a newly selected community. + if (!agent || relay.snapshot() !== connection) return; + const current = control.snapshot(); + if (action === "stop" ? !canStopAgent(current, agent.id) : !!startBlock) + return; + void control.action(agent.id, action).catch(() => {}); + }; + return ( +
+ {agent && ( + <> +
+ {agent.status !== "running" && ( + + )} + + +
+ {startBlock && ( +

{startBlock}

+ )} + {agent.error &&

{agent.error}

} + + )} + {state.error &&

{state.error}

} + {state.status === "error" && ( + <> + {agent && ( +

+ Showing the last host snapshot. Current process state and durable + enabled intent are unconfirmed. +

+ )} + + + )} + {state.busy &&

Waiting for the host to confirm…

} +
+ ); +} diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 2571dc494..172dc8154 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -1,3 +1,4 @@ +import { ProfileAgentActions } from "./ProfileAgentActions"; import { relayOrigin } from "../../features/communities/destination"; import type { AgentControl } from "../../features/agents/control"; import { ProfileInstances } from "./ProfileInstances"; @@ -6,6 +7,7 @@ import { ProfileChannels } from "./ProfileChannels"; import { useChannelIdentityNames } from "../../features/identity-names/react"; import { PresenceIndicator } from "../../features/presence/react"; import { + type ReactNode, useEffect, useMemo, useRef, @@ -52,10 +54,15 @@ export function ProfilePanel({ control={control} scope={connection.scope} viewer={connection.viewer} - /> + > + {control && ( + + )} + ); } function ProfileDetails({ + children, session, pubkey, context, @@ -64,6 +71,7 @@ function ProfileDetails({ scope, viewer, }: { + children?: ReactNode; session: RelaySession; pubkey: string; context: PanelProps["context"]; @@ -167,6 +175,7 @@ function ProfileDetails({ {profile?.about && (

{profile.about}

)} + {children} Date: Wed, 23 Sep 2026 16:58:52 -0700 Subject: [PATCH 2/6] fix: preserve profile action focus and scope errors Signed-off-by: cid --- docs/profiles.md | 12 +++- src/bundled/agents/AgentControlPanel.tsx | 25 +------ src/bundled/agents/AgentEditor.tsx | 18 ++--- src/bundled/agents/ManagedAgentActions.tsx | 15 +--- .../profiles/ProfileAgentActions.test.tsx | 68 ++++++++++++++++++- src/bundled/profiles/ProfileAgentActions.tsx | 54 +++++++-------- src/features/agents/control-react.ts | 23 +++++++ src/features/agents/control.ts | 16 +++++ tests/browser/profiles.spec.mjs | 38 +++++++++++ tests/fixtures/profiles.tsx | 22 +++++- 10 files changed, 204 insertions(+), 87 deletions(-) create mode 100644 src/features/agents/control-react.ts diff --git a/docs/profiles.md b/docs/profiles.md index ca6a6aacb..845618a27 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -167,15 +167,21 @@ before attempting again. Neither list is a cross-community/global directory. It matches the exact public key and canonical active-community scope to one native ID; namesakes, other-community identities, ambiguous matches and browser-only profiles get no runtime actions. It adds no controller, relay scan or agent editor. -While mounted it refreshes host evidence every five seconds when visible/ready, -matching Agents; errors stop polling and expose explicit Retry status. Unmount +Profiles and Agents share `useAgentControl` for observation. While mounted it +refreshes host evidence every five seconds when visible/ready; errors stop polling and expose explicit Retry status. Unmount releases observation, never native execution. Start/Restart require ready host evidence, an available runtime and no pending operation or process transition. Stop uses the controller's existing recovery policy, including stale evidence and pending launch/credential waits; it is the intentional exception to disabling pending actions. A pending Stop cannot repeat. -Host failures remain visible with snapshot uncertainty, and retired relay +Host failures remain visible with snapshot uncertainty for the matched agent. +Known unmatched profiles suppress unrelated controller errors; an initial read +failure still offers Retry while ownership is unknown. Start stays focusable but +inactive while pending, and moves focus to Stop if success removes the focused +Start button. It does not steal focus moved elsewhere during the wait. +`agentLaunchBlock` centralizes the launch gates used by Profiles and Agents. +Retired relay presentations cannot dispatch commands. The separate runtime child owns badges and runtime detail; actions do not infer relay readiness. diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index d66f369a0..34cb3886e 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -1,10 +1,6 @@ import type { useIdentityNames } from "../../features/identity-names/react"; -import { - useEffect, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; +import { useAgentControl } from "../../features/agents/control-react"; +import { useEffect, useState, type ReactNode } from "react"; import type { AgentControl, AgentControlState, @@ -50,22 +46,7 @@ export function AgentControlPanel({ } | null>(null); const edit = (agent: AgentView, avatar?: string) => setSelected({ id: agent.id, ...(avatar ? { avatar } : {}) }); - const state = useSyncExternalStore( - control.subscribe, - control.snapshot, - control.snapshot, - ); - useEffect(() => { - void control.refresh(); - const timer = setInterval(() => { - if ( - document.visibilityState !== "hidden" && - control.snapshot().status === "ready" - ) - void control.refresh(); - }, 5000); - return () => clearInterval(timer); - }, [control]); + const state = useAgentControl(control); useEffect(() => { if ( state.data?.agents.some( diff --git a/src/bundled/agents/AgentEditor.tsx b/src/bundled/agents/AgentEditor.tsx index f49b066bc..d609ba737 100644 --- a/src/bundled/agents/AgentEditor.tsx +++ b/src/bundled/agents/AgentEditor.tsx @@ -6,6 +6,7 @@ import { Accordion } from "../../shared/design-system/ui/Accordion"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { canStopAgent, + agentLaunchBlock, type AgentControl, type AgentControlState, type AgentView, @@ -43,8 +44,7 @@ export function AgentEditor({ const blocked = state.busy || state.status !== "ready"; const canClose = !state.busy || !!(state.pendingLaunch || state.pendingCredentialWrite); - const transitioning = - agent.status === "starting" || agent.status === "stopping"; + const launchBlocked = !!agentLaunchBlock(state, agent) || dirty; const unapplied = agent.runningRevision !== null && agent.runningRevision !== agent.revision; const change = (patch: Partial) => { @@ -160,12 +160,7 @@ export function AgentEditor({
{agent.status !== "running" && ( )} diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index f0ebd1bb7..eb1f7b55e 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -268,3 +268,72 @@ it.each(["action", "initial read"])( } }, ); + +it.each(["ambiguous", "unmatched"])( + "Info retains recovery for a known %s identity", + async (kind) => { + const fixture = controlFixture(); + const person = keypair(); + const owner = createRelaySession({ + viewer: key, + relayAuthor: keypair().pubkey, + media: () => undefined, + query: async () => [ + profile(person, { name: "Known agent", is_agent: true }), + ], + }); + if (kind === "ambiguous") { + fixture.agent.pubkey = person.pubkey; + fixture.data.agents.push({ ...fixture.agent, id: "duplicate" }); + } + await owner.session.profiles.ensure([person.pubkey]); + const control = createAgentControl(fixture.host); + const snapshot = { + status: "ready" as const, + generation: 1, + scope: `https://relay.example.test:${key}`, + viewer: key, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + try { + render( + {}} + />, + ); + await waitFor(() => expect(control.snapshot().status).toBe("ready")); + vi.spyOn(fixture.host, "snapshot").mockRejectedValueOnce("read failed"); + await act(() => control.refresh()); + expect(screen.getAllByRole("alert")).toHaveLength(1); + expect(screen.getByRole("alert")).toHaveTextContent( + "Managed agent status is unconfirmed.", + ); + expect( + screen.queryByRole("region", { name: "Local agent actions" }), + ).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Retry agents" })); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect( + screen.getByRole("region", { name: "Linked agent instances" }), + ).toBeInTheDocument(); + } finally { + cleanup(); + control.dispose(); + owner.dispose(); + } + }, +);