diff --git a/docs/profiles.md b/docs/profiles.md index dfb828311..15f878e30 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,44 @@ 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 mounts only in Info, alongside the linked-instance child; changing tabs releases +the actions view without cancelling an admitted app-owned command. Returning to +Info observes current host evidence without restoring focus from the retired view. +In this composition, actions own controller errors and Retry status; linked instances +suppress their duplicate error surface only when actions can present recovery +(unknown inventory or one exact match). Known unmatched/ambiguous identities keep +a single linked-instance status warning and Retry agents. Standalone +linked-instance views retain their own recovery. Initial-read Retry remains available +when native ownership is unknown. +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. +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 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. Stop and +Restart retain focus when disabled or pending without allowing activation. +`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. + +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/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" && ( + )} + + +
+ {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/ProfileInstances.tsx b/src/bundled/profiles/ProfileInstances.tsx index e478e3328..fe6101203 100644 --- a/src/bundled/profiles/ProfileInstances.tsx +++ b/src/bundled/profiles/ProfileInstances.tsx @@ -13,6 +13,7 @@ export function ProfileInstances({ communityOrigin, viewer, knownAgent, + errorHandledByActions = false, }: { control: AgentControl; navigation: Navigation | undefined; @@ -21,6 +22,8 @@ export function ProfileInstances({ communityOrigin: string | undefined; viewer: string | undefined; knownAgent: boolean; + /** The composed Info actions surface owns controller failure and recovery. */ + errorHandledByActions?: boolean; }) { const state = useSyncExternalStore( control.subscribe, @@ -32,12 +35,19 @@ export function ProfileInstances({ void control.refresh(); }, [control, communityOrigin, knownAgent, state.status]); if (!communityOrigin || state.status === "unavailable") return null; - const instances = - scope && state.status === "ready" - ? sameCommunityAgents(state.data?.agents ?? [], scope).filter( - (agent) => agent.pubkey === pubkey, - ) - : []; + const matches = scope + ? sameCommunityAgents(state.data?.agents ?? [], scope).filter( + (agent) => agent.pubkey === pubkey, + ) + : []; + // Actions own errors only with unknown inventory or one exact native match. + if ( + state.status === "error" && + errorHandledByActions && + (!state.data || matches.length === 1) + ) + return null; + const instances = state.status === "ready" ? matches : []; if (!knownAgent && !instances.length) return null; return (
Loading managed agents…

) : state.status === "error" ? (
-

Could not refresh managed agents.

+

+ {errorHandledByActions + ? "Managed agent status is unconfirmed." + : "Could not refresh managed agents."} +

diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index fe146f4df..eb1f7b55e 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -1,4 +1,8 @@ // @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import userEvent from "@testing-library/user-event"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import { createRelaySession } from "../../features/relay/session"; @@ -187,3 +191,149 @@ it("recovers provider-owned names in the same live session and releases demand w owner.dispose(); } }); + +it.each(["action", "initial read"])( + "Info gives %s failures one recovery owner", + async (failure) => { + const fixture = controlFixture(); + const person = keypair(); + const owner = createRelaySession({ + viewer: key, + relayAuthor: keypair().pubkey, + media: () => undefined, + query: async () => [ + profile(person, { name: "Owned agent", is_agent: true }), + ], + }); + 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 () => {}, + }; + // Seed a known-agent hint; authority still comes only from the native snapshot. + fixture.agent.pubkey = person.pubkey; + await owner.session.profiles.ensure([person.pubkey]); + if (failure === "initial read") + vi.spyOn(fixture.host, "snapshot").mockRejectedValueOnce( + "snapshot rejected", + ); + else + vi.spyOn(fixture.host, "action").mockRejectedValueOnce("stop rejected"); + const user = userEvent.setup(); + try { + render( + {}} + />, + ); + if (failure === "action") + await user.click(await screen.findByRole("button", { name: "Stop" })); + await screen.findByText( + failure === "action" + ? /stop rejected/ + : /Could not refresh local agents/, + ); + expect(screen.getAllByRole("alert")).toHaveLength(1); + expect( + screen.queryByRole("button", { name: "Retry agents" }), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry status" })); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect( + screen.getByRole("region", { name: "Linked agent instances" }), + ).toHaveTextContent(fixture.agent.name); + expect(screen.getByRole("button", { name: "Stop" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + } finally { + cleanup(); + control.dispose(); + owner.dispose(); + } + }, +); + +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(); + } + }, +); diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 2571dc494..cd16ed8cf 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} {control && ( { + void control.refresh(); + const timer = setInterval(() => { + if ( + document.visibilityState !== "hidden" && + control.snapshot().status === "ready" + ) + void control.refresh(); + }, 5000); + return () => clearInterval(timer); + }, [control]); + return state; +} diff --git a/src/features/agents/control.ts b/src/features/agents/control.ts index e2c71cdf5..62c4bcaf3 100644 --- a/src/features/agents/control.ts +++ b/src/features/agents/control.ts @@ -131,6 +131,22 @@ export interface AgentControl { dismissMentionError(): void; } +/** Shared launch availability; Stop intentionally has its own recovery policy. */ +export function agentLaunchBlock( + state: AgentControlState, + agent: AgentView, +): string | null { + if (state.status !== "ready") return "Refresh status before starting."; + if (state.busy) return "Waiting for the current operation."; + if (!state.data?.runtimeAvailable) + return ( + state.data?.runtimeMessage || "The bundled agent runtime is unavailable." + ); + if (agent.status === "starting" || agent.status === "stopping") + return "Waiting for the process transition."; + return null; +} + /** Stop is recovery, not a launch: stale stopped/disabled evidence cannot veto it. */ export function canStopAgent(state: AgentControlState, id: string): boolean { if ( diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 55fdd941a..aaa88ecbf 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -251,3 +251,98 @@ test("contextual panel callbacks retire with opening, channel, contribution and expect(await invoke()).toBe(false); await expect(panel).toHaveCount(0); }); + +// Native button disabling/removal can move focus differently from jsdom. Exercise +// actual keyboard focus in both engines with a gated synthetic host, not real agents. +test("local agent actions retain keyboard focus through pending and success without stealing focus", async ({ + page, +}) => { + await page.goto("/tests/fixtures/profiles.html?agent-actions"); + await page + .getByRole("button", { name: "View Mic profile", exact: true }) + .click(); + const panel = page.getByRole("region", { name: "Local agent actions" }); + const start = panel.getByRole("button", { name: "Start", exact: true }); + const stop = panel.getByRole("button", { name: "Stop", exact: true }); + await start.focus(); + await start.press("Enter"); + try { + await page.waitForFunction(() => window.profilesFixture.launchPending()); + await expect(start).toBeDisabled(); + await expect(start).toBeFocused(); + } finally { + await page.evaluate(() => window.profilesFixture.finishLaunch()); + } + await expect(start).toHaveCount(0); + await expect(stop).toBeFocused(); + await stop.press("Enter"); + await expect(start).toBeEnabled(); + await expect(stop).toBeDisabled(); + await expect(stop).toBeFocused(); + await stop.press("Enter"); + await start.focus(); + await start.press("Enter"); + const copy = page.getByRole("button", { name: "Copy npub" }); + try { + await page.waitForFunction(() => window.profilesFixture.launchPending()); + await copy.focus(); + } finally { + await page.evaluate(() => window.profilesFixture.finishLaunch()); + } + await expect(start).toHaveCount(0); + await expect(copy).toBeFocused(); + const restart = panel.getByRole("button", { name: "Restart", exact: true }); + await restart.focus(); + await restart.press("Enter"); + try { + await page.waitForFunction(() => window.profilesFixture.launchPending()); + await expect(restart).toBeDisabled(); + await expect(restart).toBeFocused(); + await restart.press("Enter"); + } finally { + await page.evaluate(() => window.profilesFixture.finishLaunch()); + } + await expect(restart).toBeEnabled(); + await expect(restart).toBeFocused(); + expect(await page.evaluate(() => window.profilesFixture.commands())).toEqual([ + "start", + "stop", + "start", + "restart", + ]); +}); + +// Switching profile tabs must release the actions view, not its app-owned command. +test("local agent command survives Info tab unmount without stealing tab focus", async ({ + page, +}) => { + await page.goto("/tests/fixtures/profiles.html?agent-actions"); + await page + .getByRole("button", { name: "View Mic profile", exact: true }) + .click(); + const actions = page.getByRole("region", { name: "Local agent actions" }); + await actions + .getByRole("button", { name: "Start", exact: true }) + .press("Enter"); + const channels = page.getByRole("tab", { name: "Channels", exact: true }); + const info = page.getByRole("tab", { name: "Info", exact: true }); + try { + await page.waitForFunction(() => window.profilesFixture.launchPending()); + await channels.click(); + await expect(actions).toHaveCount(0); + } finally { + await page.evaluate(() => window.profilesFixture.finishLaunch()); + } + await expect(channels).toBeFocused(); + await info.click(); + await expect( + actions.getByRole("button", { name: "Start", exact: true }), + ).toHaveCount(0); + await expect( + actions.getByRole("button", { name: "Stop", exact: true }), + ).toBeEnabled(); + await expect(info).toBeFocused(); + expect(await page.evaluate(() => window.profilesFixture.commands())).toEqual([ + "start", + ]); +}); diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx index 494b99c56..10b8060f3 100644 --- a/tests/fixtures/profiles.tsx +++ b/tests/fixtures/profiles.tsx @@ -1,3 +1,4 @@ +import { controlFixture } from "../../src/features/agents/control-testing"; // Real ChannelsPage, thread reader, shared directory, panel registry and plugin lifecycle. // Only the transport is synthetic. No dev broker, saved identity or live relay. import { StrictMode, useLayoutEffect, useState } from "react"; @@ -39,6 +40,7 @@ const viewer = keypair(), mic = keypair(), pinky = keypair(), missing = keypair(); +const actionsProbe = new URLSearchParams(location.search).has("agent-actions"); const root = message(viewer, "one", "Hello @Mic", 10, [["p", mic.pubkey]]); const unknown = message(missing, "one", "Unknown author", 11); const reply = message(viewer, "one", "Thread @Pinky", 12, [ @@ -171,7 +173,22 @@ context.provide("relay", relay); const navigationHost = createNavigationController(createMemoryHistory()); context.provide("navigation", navigationHost.navigation); context.effect(() => () => navigationHost.dispose()); -const agentControl = createAgentControl(null); +const native = controlFixture(); +native.agent.pubkey = mic.pubkey; +native.agent.status = "stopped"; +native.agent.enabled = false; +let releaseLaunch: (() => void) | undefined; +const commands: string[] = []; +const action = native.host.action; +native.host.action = async (id, command) => { + commands.push(command); + if (command === "start" || command === "restart") + await new Promise((resolve) => { + releaseLaunch = resolve; + }); + return action(id, command); +}; +const agentControl = createAgentControl(actionsProbe ? native.host : null); context.provide("agentControl", agentControl); context.effect(() => () => agentControl.dispose()); const contexts: PanelContext[] = []; @@ -212,6 +229,12 @@ const providers = new TemplateProvidersService(context); Object.assign(window, { profilesFixture: { report, + commands: () => [...commands], + launchPending: () => !!releaseLaunch, + finishLaunch: () => { + releaseLaunch?.(); + releaseLaunch = undefined; + }, contexts, targets: { viewer: profileTarget(viewer.pubkey),