diff --git a/docs/profiles.md b/docs/profiles.md index 21edb16e5..dfe955177 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -13,8 +13,10 @@ custody is app-wide: the viewer is not an owner check, so any account in this app on the same relay sees the same local record. It shows process status (still "relay readiness unverified", not a listening badge), saved harness/provider/model/workspace (labelled as saved settings, since -environment overrides may change what is launched), collapsible instructions, -saved-vs-running revision drift and diagnostics. Environment keys and +environment overrides may change what is launched), saved-vs-running revision +drift and diagnostics. Instructions are no longer displayed in the read-only +summary; the verified owner opens the existing native editor in place with +**Agent instructions**, without leaving the profile. Environment keys and arguments are not shown. Opening the Info tab requests a status read; concurrent requests coalesce. This summary adds no polling and observes the profile actions' existing refresh. A failed read keeps the last evidence. @@ -22,8 +24,9 @@ The host error, runtime-unavailable reason, unconfirmed-status notice and Retry are shown once, by the profile actions. Browser/unavailable hosts, loading/error without evidence, unknown keys and records saved for another community render nothing, leaving the public identity. Community switches re-filter immediately. -Start/Stop/Restart are the separate profile actions above; harness logs stay on -Agents. +Start/Stop/Restart are the separate profile actions above. The owner-only +**Agent instructions** ingress in Info requires a unique native record; harness +logs stay on Agents. ## Owner runtime tab @@ -276,7 +279,10 @@ unambiguous native match; an instance view selects that record by ID. Archive labels come from `session.archives`, keyed by identity, rather than native process status. Archived rows use the same explicit target and remain navigable. The existing public profile tabs and actions are reused, not replaced by an Agents -page or a second editor. Browser fixtures exercise live/archived selection, +page or a second editor. The owner-only ingress requires a verified NIP-OA +owner and a ready, unique native match in this community; the versioned +agent-identity route opens the existing editor, never a guessed sibling. +Browser fixtures exercise live/archived selection, tabs, back, keyboard close/focus and deletion through the real plugin/Channels host in Chromium and WebKit. React tests cover exact actions, failed-read recovery, non-owner denial and scope changes with synthetic native data. @@ -336,8 +342,9 @@ Retired relay presentations cannot dispatch commands. The separate runtime child owns badges and runtime detail; actions do not infer relay readiness. -Info adds no edit ingress; the owner Runtime tab reuses the Agents editor -dialog rather than a route or second editor. Mounted React regression tests exercise exact dispatch, pending/failure/ +Info adds an owner-gated route to the Agents editor for a unique native match; +the owner Runtime tab also reuses the editor dialog. 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/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index 3296d5b59..e7fcc5b70 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -1,6 +1,13 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { finalizeEvent, getPublicKey } from "nostr-tools"; @@ -9,6 +16,14 @@ import { createServices, type AppServices } from "./services"; import { bindDeepLinks } from "../features/navigation/deep-links"; import { matchesEvent } from "../features/relay/projection"; import type { ReadFilter } from "../features/relay/events"; +import { createAgentControl } from "../features/agents/control"; +import { controlFixture } from "../features/agents/control-testing"; + +let agentFixture: ReturnType | undefined; +vi.mock("../features/agents/control-native", () => ({ + createNativeAgentControl: () => + createAgentControl(agentFixture?.host ?? null), +})); vi.mock("../bundled", async () => ({ bundledPlugins: [ @@ -16,6 +31,10 @@ vi.mock("../bundled", async () => ({ manifest: { id: "buzz.projects", name: "Projects", apiVersion: 1 }, module: await import("../bundled/projects"), }, + { + manifest: { id: "buzz.agents", name: "Agents", apiVersion: 1 }, + module: await import("../bundled/agents"), + }, ], })); const key = new Uint8Array(32).fill(6), @@ -41,6 +60,7 @@ afterEach(async () => { stop(); await services?.dispose(); services = undefined; + agentFixture = undefined; vi.unstubAllGlobals(); vi.unstubAllEnvs(); localStorage.clear(); @@ -129,3 +149,90 @@ it("keeps an OS entity intent through real community selection and Retry in App" screen.queryByRole("button", { name: "Retry navigation" }), ).not.toBeInTheDocument(); }); + +it("reconnects a failed routed Agents edit from the shell and opens its exact editor", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubEnv("VITE_BUZZ_LIVE", "1"); + agentFixture = controlFixture(); + agentFixture.agent.relayUrl = "wss://community.example"; + agentFixture.data.agents.push({ + ...structuredClone(agentFixture.agent), + id: "different-agent", + pubkey: "cd".repeat(32), + name: "Different agent", + }); + localStorage.setItem( + `buzz-client.v1:${viewer}`, + JSON.stringify({ + profile: { name: "Fixture", picture: "" }, + memberships: [{ id: origin, name: "Fixture community" }], + selected: origin, + }), + ); + let attempts = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.endsWith("/identity")) return Response.json({ viewer }); + if (url.endsWith("/register")) return Response.json({}); + if (url.endsWith("/session")) { + attempts++; + return attempts === 1 + ? Response.json({ error: "relay offline" }, { status: 503 }) + : Response.json({ + viewer, + relayAuthor: viewer, + relayUrl: "wss://community.example", + }); + } + if (url.endsWith("/query")) return Response.json([]); + return Response.json({}); + }), + ); + services = createServices(); + const current = services; + const target = { + version: 1 as const, + kind: "page" as const, + pluginId: "buzz.agents", + pageId: "agents", + scope: { viewer, communityOrigin: origin }, + route: { + version: 1 as const, + params: { pubkey: agentFixture.agent.pubkey }, + }, + }; + render(); + await waitFor(() => expect(current.relay.snapshot().status).toBe("error")); + void current.navigation.open(target); + await screen.findByRole("button", { name: "Retry navigation" }); + expect(current.navigation.snapshot()).toMatchObject({ + status: "failed", + reason: "unavailable", + entry: { target }, + }); + expect(screen.queryByRole("dialog", { name: "Edit agent" })).toBeNull(); + expect(attempts).toBe(1); + + await userEvent.click( + screen.getByRole("button", { name: "Retry navigation" }), + ); + await waitFor(() => expect(current.relay.snapshot().status).toBe("ready")); + const dialog = await screen.findByRole("dialog", { name: "Edit agent" }); + expect(within(dialog).getByLabelText("Name")).toHaveValue("Fixture agent"); + expect(within(dialog).getByLabelText("Agent instructions")).toHaveValue( + "Help with the project.", + ); + expect(current.navigation.snapshot()).toMatchObject({ + status: "opened", + entry: { target }, + }); + expect(attempts).toBe(2); +}); diff --git a/src/app/navigation.ts b/src/app/navigation.ts index 63154791d..f7467d520 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -243,7 +243,9 @@ export function useAppNavigation(services: AppServices) { // Retrying presentation must also repair its failed dependency. Only touch the // selected, authorized destination; never reconnect an unrelated community. if ( - (pageKey === channelsKey || pageKey === "buzz.projects/projects") && + (pageKey === channelsKey || + pageKey === "buzz.projects/projects" || + pageKey === "buzz.agents/agents") && !state.ingress && !failure && !waiting diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index a20a0dedf..95e317fe5 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -1,5 +1,7 @@ import type { useIdentityNames } from "../../features/identity-names/react"; import { useAgentControl } from "../../features/agents/control-react"; +import { sameCommunityAgents } from "../../features/agents/choices"; +import type { PageNavigation } from "../../features/navigation/service"; import { useEffect, useState, type ReactNode } from "react"; import type { AgentControl, @@ -23,11 +25,17 @@ export function AgentControlPanel({ createOwner, resolveName, children, + editTarget, + editRequest, + onCloseTarget, }: { resolveName?: ReturnType; control: AgentControl; importDestination?: string; createOwner?: string | undefined; + editTarget?: string | null; + editRequest?: PageNavigation; + onCloseTarget?: () => void; children?: ( state: AgentControlState, edit: (agent: AgentView, avatar?: string) => void, @@ -49,8 +57,10 @@ export function AgentControlPanel({ avatar?: string; } | null>(null); const [deleting, setDeleting] = useState(null); - const edit = (agent: AgentView, avatar?: string) => + const edit = (agent: AgentView, avatar?: string) => { setSelected({ id: agent.id, ...(avatar ? { avatar } : {}) }); + if (editTarget) onCloseTarget?.(); + }; const duplicate = (agent: AgentView) => setAdding({ destination: agent.relayUrl, @@ -81,7 +91,36 @@ export function AgentControlPanel({ ...facts, { pubkey: agent.pubkey, name: agent.name, isAgent: true }, ]) ?? agent.name; - const editing = state.data?.agents.find((agent) => agent.id === selected?.id); + // Route selection takes precedence over card-local editing. Never guess among + // multiple native records for the same public identity in this community. + const routed = + editTarget && importDestination && createOwner + ? sameCommunityAgents( + state.data?.agents ?? [], + `${importDestination}:${createOwner}`, + ).filter((agent) => agent.pubkey === editTarget) + : []; + const editing = editTarget + ? routed.length === 1 + ? routed[0] + : undefined + : state.data?.agents.find((agent) => agent.id === selected?.id); + useEffect(() => { + if ( + !editRequest || + editRequest.signal.aborted || + state.status === "loading" || + state.status === "idle" + ) + return; + if (state.status !== "ready") { + editRequest.complete({ status: "failed", reason: "unavailable" }); + } else if (editing) { + editRequest.complete({ status: "opened" }); + } else { + editRequest.complete({ status: "failed", reason: "not-found" }); + } + }, [editRequest, editing, state.status]); const deletion = state.data?.agents.find((agent) => agent.id === deleting); return (
setSelected(null)} + avatar={editTarget ? undefined : selected?.avatar} + onClose={ + editTarget ? (onCloseTarget ?? (() => {})) : () => setSelected(null) + } /> )} {deletion && control.delete && ( diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index f9d148954..6b2fcc9da 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -15,6 +15,8 @@ import { import userEvent from "@testing-library/user-event"; import * as communityApi from "../../features/communities/api"; import { AgentsPage } from "./AgentsPage"; +import type { PageNavigation } from "../../features/navigation/service"; +import type { OpenTarget } from "../../features/navigation/targets"; import { createAgentControl } from "../../features/agents/control"; import { controlFixture } from "../../features/agents/control-testing"; import { createRelaySession } from "../../features/relay/session"; @@ -30,6 +32,11 @@ afterEach(() => { function setup( mode = "ready", configure?: (fixture: ReturnType) => void, + navigation?: PageNavigation, + open?: ( + target: OpenTarget, + options?: { replace?: boolean }, + ) => Promise<{ status: "opened" }>, ) { const f = controlFixture(); configure?.(f); @@ -76,7 +83,12 @@ function setup( } : owned.session; let snapshot: RelaySnapshot = { - status: mode === "disconnected" ? "disconnected" : "ready", + status: + mode === "disconnected" + ? "disconnected" + : mode === "connecting" + ? "connecting" + : "ready", scope: mode === "connected" ? `wss://relay.example.test:${"de".repeat(32)}` @@ -104,7 +116,14 @@ function setup( }); snapshot = { ...snapshot, session: { ...session, names } }; disposals.push(() => names.dispose()); - render(); + render( + , + ); return { f, read, @@ -113,6 +132,16 @@ function setup( snapshot = { status: "ready", scope, generation, session }; for (const listener of listeners) listener(); }, + connect() { + snapshot = { + status: "ready", + scope: `wss://relay.example.test:${"de".repeat(32)}`, + viewer: "de".repeat(32), + generation: snapshot.generation, + session: snapshot.session, + }; + for (const listener of listeners) listener(); + }, }; } it("shows one managed card per exact destination and keeps unimported templates out of My agents", async () => { @@ -1384,3 +1413,189 @@ it("keeps collisions across different cross-community aliases and edits the exac ), ); }); + +function routed(pubkey: string) { + const complete = vi.fn(() => true); + const target: OpenTarget = { + version: 1, + kind: "page", + pluginId: "buzz.agents", + pageId: "agents", + scope: { + viewer: "de".repeat(32), + communityOrigin: "https://relay.example.test", + }, + route: { version: 1, params: { pubkey } }, + }; + const navigation = { + target, + signal: new AbortController().signal, + complete, + forSession() { + return this; + }, + } as unknown as PageNavigation; + return { navigation, complete }; +} + +it("opens the exact native agent editor on the routed page and acknowledges its presentation", async () => { + const { navigation, complete } = routed("ab".repeat(32)); + const { f } = setup("connected", undefined, navigation); + const dialog = await screen.findByRole("dialog", { name: "Edit agent" }); + expect(within(dialog).getByLabelText("Agent instructions")).toBeVisible(); + await waitFor(() => + expect(complete).toHaveBeenCalledWith({ status: "opened" }), + ); + fireEvent.change(within(dialog).getByLabelText("Name"), { + target: { value: "Targeted" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Save changes" })); + await waitFor(() => + expect( + f.calls.find((call) => call.action === "save")?.payload, + ).toMatchObject({ id: "fixture-agent" }), + ); +}); + +it("rejects missing and ambiguous route targets instead of choosing a namesake", async () => { + const { navigation, complete } = routed("ab".repeat(32)); + setup( + "connected", + (f) => f.data.agents.push({ ...structuredClone(f.agent), id: "duplicate" }), + navigation, + ); + await waitFor(() => + expect(complete).toHaveBeenCalledWith({ + status: "failed", + reason: "not-found", + }), + ); + expect(screen.queryByRole("dialog", { name: "Edit agent" })).toBeNull(); +}); + +it("rejects an edit route for a different community", async () => { + const { navigation, complete } = routed("ab".repeat(32)); + setup( + "connected", + (f) => { + f.agent.relayUrl = "wss://other.example"; + }, + navigation, + ); + await waitFor(() => + expect(complete).toHaveBeenCalledWith({ + status: "failed", + reason: "not-found", + }), + ); + expect(screen.queryByRole("dialog", { name: "Edit agent" })).toBeNull(); +}); + +it("closes a routed editor back to the unrouted Agents page", async () => { + const { navigation } = routed("ab".repeat(32)); + const open = vi.fn( + async (_target: OpenTarget, _options?: { replace?: boolean }) => ({ + status: "opened" as const, + }), + ); + setup("connected", undefined, navigation, open); + const dialog = await screen.findByRole("dialog", { name: "Edit agent" }); + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Close editor" })); + expect(open).toHaveBeenCalledWith( + { + version: 1, + kind: "page", + pluginId: "buzz.agents", + pageId: "agents", + scope: { + viewer: "de".repeat(32), + communityOrigin: "https://relay.example.test", + }, + }, + { replace: true }, + ); +}); + +it("retains a routed draft when the native save fails", async () => { + const { navigation } = routed("ab".repeat(32)); + const { f } = setup( + "connected", + (fixture) => fixture.failSave(true), + navigation, + ); + const dialog = await screen.findByRole("dialog", { name: "Edit agent" }); + fireEvent.change(within(dialog).getByLabelText("Name"), { + target: { value: "Unsaved draft" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Save changes" })); + await within(dialog).findByText(/Your edits are retained/); + expect(within(dialog).getByLabelText("Name")).toHaveValue("Unsaved draft"); + expect(f.calls.some((call) => call.action === "save")).toBe(true); +}); + +it("waits for a connecting relay before opening a routed editor", async () => { + const { navigation, complete } = routed("ab".repeat(32)); + const page = setup("connecting", undefined, navigation); + expect(complete).not.toHaveBeenCalled(); + await waitFor(() => expect(page.control.snapshot().status).toBe("ready")); + expect(complete).not.toHaveBeenCalled(); + act(() => page.connect()); + await screen.findByRole("dialog", { name: "Edit agent" }); + await waitFor(() => + expect(complete).toHaveBeenCalledWith({ status: "opened" }), + ); + expect(complete).not.toHaveBeenCalledWith({ + status: "failed", + reason: "unavailable", + }); +}); + +it("acknowledges the unrouted Agents page", async () => { + const { navigation, complete } = routed("ab".repeat(32)); + const target = navigation.target as Extract; + const unrouted = { + ...navigation, + target: { ...target, route: undefined }, + } as unknown as PageNavigation; + setup("connected", undefined, unrouted); + await waitFor(() => + expect(complete).toHaveBeenCalledWith({ status: "opened" }), + ); +}); + +it("clears an obsolete route before editing another card", async () => { + const { navigation } = routed("ab".repeat(32)); + const open = vi.fn( + async (_target: OpenTarget, _options?: { replace?: boolean }) => ({ + status: "opened" as const, + }), + ); + setup( + "connected", + (f) => { + f.data.agents.splice(0, 1); + }, + navigation, + open, + ); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Edit agent" })).toBeNull(), + ); + const cards = await screen.findAllByRole("article", { + name: "Agent Fixture agent", + }); + const other = cards.find((card) => + card.textContent?.includes("wss://second.example"), + ); + if (!other) throw Error("Second destination missing"); + fireEvent.click( + within(other).getByRole("button", { name: "Actions for Fixture agent" }), + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); + expect(open).toHaveBeenCalledWith( + expect.not.objectContaining({ route: expect.anything() }), + { replace: true }, + ); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 1b76cddcd..a6aa5c6fa 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -1,5 +1,9 @@ import { useIdentityNames } from "../../features/identity-names/react"; -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import type { PageProps } from "../../features/pages/service"; +import type { OpenTarget } from "../../features/navigation/targets"; +import type { OpenResult } from "../../features/navigation/controller"; +import { editAgentRoute } from "./edit-route"; import type { AgentControl, AgentControlState, @@ -18,12 +22,40 @@ import { ManagedAgentActions } from "./ManagedAgentActions"; export function AgentsPage({ relay, control, -}: { + navigation, + open, +}: PageProps & { relay: RelayData; control?: AgentControl; + open?: ( + target: OpenTarget, + options?: { replace?: boolean }, + ) => Promise; }) { const connection = useRelayConnection(relay); const resolveName = useIdentityNames(connection.session.names); + const request = useMemo( + () => navigation?.forSession(relay, connection), + [navigation, relay, connection], + ); + const target = request?.target; + const editTarget = + target?.kind === "page" && target.route + ? editAgentRoute(target.route.params) + : null; + useEffect(() => { + if (!request || request.signal.aborted) return; + // The routed edit destination must not acknowledge an unrelated page. + if (target?.kind !== "page") return; + if (!editTarget && !target.route) request.complete({ status: "opened" }); + else if (!editTarget) + request.complete({ status: "failed", reason: "unavailable" }); + else if ( + !control || + (connection.status !== "ready" && connection.status !== "connecting") + ) + request.complete({ status: "failed", reason: "unavailable" }); + }, [request, target, editTarget, control, connection.status]); let importDestination = ""; if ( connection.viewer && @@ -62,6 +94,25 @@ export function AgentsPage({ {control ? ( { + if (target?.kind === "page" && open) + void open( + { + version: 1, + kind: "page", + pluginId: target.pluginId, + pageId: target.pageId, + ...(target.scope !== undefined + ? { scope: target.scope } + : {}), + }, + { replace: true }, + ); + }} resolveName={resolveName} importDestination={importDestination} createOwner={ diff --git a/src/bundled/agents/edit-route.test.ts b/src/bundled/agents/edit-route.test.ts new file mode 100644 index 000000000..53c84b7a1 --- /dev/null +++ b/src/bundled/agents/edit-route.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from "vitest"; +import { editAgentRoute } from "./edit-route"; + +const pubkey = "a".repeat(64); +it("accepts only the exact lowercase agent identity route", () => { + expect(editAgentRoute({ pubkey })).toBe(pubkey); + for (const params of [ + null, + [], + {}, + { pubkey: pubkey.toUpperCase() }, + { pubkey, action: "save" }, + { pubkey: "not-a-key" }, + { id: pubkey }, + ]) { + expect(editAgentRoute(params)).toBeNull(); + } +}); diff --git a/src/bundled/agents/edit-route.ts b/src/bundled/agents/edit-route.ts new file mode 100644 index 000000000..808a271f4 --- /dev/null +++ b/src/bundled/agents/edit-route.ts @@ -0,0 +1,13 @@ +import type { JsonValue } from "../../features/navigation/targets"; + +/** A public identity is an address; the native snapshot supplies the exact edit record. */ +export function editAgentRoute(params: JsonValue): string | null { + if (!params || typeof params !== "object" || Array.isArray(params)) + return null; + const keys = Object.keys(params); + if (keys.length !== 1 || keys[0] !== "pubkey") return null; + const pubkey = (params as { readonly pubkey: JsonValue }).pubkey; + return typeof pubkey === "string" && /^[0-9a-f]{64}$/.test(pubkey) + ? pubkey + : null; +} diff --git a/src/bundled/agents/index.tsx b/src/bundled/agents/index.tsx index 2a7511715..d9d3e32af 100644 --- a/src/bundled/agents/index.tsx +++ b/src/bundled/agents/index.tsx @@ -1,6 +1,7 @@ import type { PluginModule } from "../../plugins/api"; import { AgentsPage } from "./AgentsPage"; -export const inject = ["pages", "relay", "agentControl"]; +import { editAgentRoute } from "./edit-route"; +export const inject = ["pages", "relay", "agentControl", "navigation"]; export const apply: PluginModule["apply"] = (ctx) => { const relay = ctx.relay; const control = ctx.agentControl; @@ -8,6 +9,18 @@ export const apply: PluginModule["apply"] = (ctx) => { id: "agents", title: "Agents", layout: "workspace", - component: () => , + handlesNavigation: true, + route: { + version: 1, + validate: (params) => editAgentRoute(params) !== null, + }, + component: (props) => ( + + ), }); }; diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx index 0eeafe5e8..abcd56c44 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.test.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -2,12 +2,22 @@ import "@testing-library/jest-dom/vitest"; import { createHash } from "node:crypto"; import { schnorr } from "@noble/curves/secp256k1.js"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { bytesToHex } from "nostr-tools/utils"; import { StrictMode } from "react"; import { afterEach, expect, it, vi } from "vitest"; import { attestedOwner } from "../../features/agents/owner-attestation"; +import { controlFixture } from "../../features/agents/control-testing"; +import { createAgentControl } from "../../features/agents/control"; +import type { Navigation } from "../../features/navigation/controller"; import { profileTarget } from "../../features/profiles/target"; import type { ReadFilter, RelayEvent } from "../../features/relay/events"; import type { LiveCallbacks } from "../../features/relay/live"; @@ -622,3 +632,109 @@ it.each(["remove", "replace", "equal-time removal"])( } }, ); + +it("offers instructions only for a signed owner with a unique native instance", async () => { + const agent = keypair(); + const ownerKey = keypair(); + const outsider = keypair(); + for (const viewer of [ownerKey, outsider]) { + const head = timedProfile(agent, [auth(agent, ownerKey)], 3); + const h = createRelaySession({ + viewer: viewer.pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + media: () => undefined, + query: async (filters) => + filters.some((filter) => filter.kinds?.includes(0)) ? [head] : [], + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }); + owners.push(h); + const fixture = controlFixture(); + fixture.agent.pubkey = agent.pubkey; + const control = createAgentControl(fixture.host); + const snapshot = { + status: "ready" as const, + generation: 1, + viewer: viewer.pubkey, + scope: `https://relay.example.test:${viewer.pubkey}`, + session: h.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const open = vi.fn(async () => ({ status: "opened" as const })); + const panel = render( + {}} + />, + ); + await screen.findByRole("heading", { name: "Helper" }); + await waitFor(() => + expect( + screen.getByRole("region", { name: "Linked agent instances" }), + ).toHaveTextContent("Fixture agent"), + ); + if (viewer === ownerKey) { + const button = await screen.findByRole("button", { + name: "Agent instructions", + }); + expect(screen.queryByText("Instructions")).toBeNull(); + expect( + screen.getAllByRole("button", { name: "Agent instructions" }), + ).toHaveLength(1); + await userEvent.setup().click(button); + const dialog = await screen.findByRole("dialog", { name: "Edit agent" }); + expect(within(dialog).getByLabelText("Agent instructions")).toHaveValue( + "Help with the project.", + ); + expect(open).not.toHaveBeenCalled(); + await userEvent + .setup() + .clear(within(dialog).getByLabelText("Agent instructions")); + await userEvent + .setup() + .type( + within(dialog).getByLabelText("Agent instructions"), + "Updated instructions.", + ); + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Save changes" })); + await waitFor(() => + expect(fixture.calls.some((call) => call.action === "save")).toBe(true), + ); + expect( + fixture.calls.find((call) => call.action === "save")?.payload, + ).toMatchObject({ + id: fixture.agent.id, + expectedRevision: 1, + edit: { systemPrompt: "Updated instructions." }, + }); + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Close editor" })); + expect(screen.queryByRole("dialog", { name: "Edit agent" })).toBeNull(); + expect( + screen.getByRole("region", { name: "Profile details" }), + ).toBeVisible(); + expect(open).not.toHaveBeenCalled(); + } else { + await screen.findByRole("region", { name: "Linked agent instances" }); + expect(screen.queryByText("Instructions")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Agent instructions" }), + ).toBeNull(); + expect(open).not.toHaveBeenCalled(); + } + panel.unmount(); + control.dispose(); + } +}); diff --git a/src/bundled/profiles/ProfileAgentRuntime.test.tsx b/src/bundled/profiles/ProfileAgentRuntime.test.tsx index 1dd161fcc..7f5e33b27 100644 --- a/src/bundled/profiles/ProfileAgentRuntime.test.tsx +++ b/src/bundled/profiles/ProfileAgentRuntime.test.tsx @@ -167,8 +167,7 @@ it("summarizes only the exact key in the active community, and drops it when the screen.getByRole("region", { name: "Local agent actions" }), ).toHaveTextContent("Last start exited early."); expect(summary).not.toHaveTextContent("Process stopped"); - await userEvent.click(within(summary).getByText("Instructions")); - expect(within(summary).getByText("Help with the project.")).toBeVisible(); + expect(within(summary).queryByText("Instructions")).toBeNull(); await userEvent.click(within(summary).getByText("Host diagnostics")); expect( within(summary).getByText(/spawned pid 42\s+listener exited/), diff --git a/src/bundled/profiles/ProfileAgentRuntime.tsx b/src/bundled/profiles/ProfileAgentRuntime.tsx index 8438c16c9..1d02a172c 100644 --- a/src/bundled/profiles/ProfileAgentRuntime.tsx +++ b/src/bundled/profiles/ProfileAgentRuntime.tsx @@ -1,23 +1,28 @@ -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import type { AgentControl } from "../../features/agents/control"; import { exactProfileAgent } from "../../features/profiles/instance-target"; import { agentProcessLabel } from "../agents/agent-edit"; +import { AgentEditor } from "../agents/AgentEditor"; +import { Button } from "../../shared/design-system/ui/Button"; import styles from "./Profiles.module.css"; -/** Read-only native evidence for this exact key in the active community. Anything - * else renders nothing, leaving the ordinary public profile. Errors, runtime - * availability and status recovery belong to ProfileAgentActions. */ +/** Native status and saved-settings summary for this exact key in the active + * community. The verified owner can open the existing editor in place when the + * native identity is unambiguous; the caller supplies ownership evidence. */ export function ProfileAgentRuntime({ control, scope, pubkey, instanceId, + owned = false, }: { control: AgentControl; scope: string; pubkey: string; instanceId?: string | undefined; + owned?: boolean; }) { + const [editing, setEditing] = useState(false); const state = useSyncExternalStore( control.subscribe, control.snapshot, @@ -27,6 +32,9 @@ export function ProfileAgentRuntime({ void control.refresh(); }, [control]); const data = state.data; + const uniqueAgent = data + ? exactProfileAgent(data.agents, scope, pubkey) + : undefined; const agent = data ? exactProfileAgent(data.agents, scope, pubkey, instanceId) : undefined; @@ -64,11 +72,16 @@ export function ProfileAgentRuntime({ )} - {agent.systemPrompt && ( -
- Instructions -

{agent.systemPrompt}

-
+ {owned && uniqueAgent?.id === agent.id && state.status === "ready" && ( + )} {!!agent.diagnostics.length && (
@@ -78,6 +91,14 @@ export function ProfileAgentRuntime({
)} + {editing && owned && uniqueAgent?.id === agent.id && ( + setEditing(false)} + /> + )}
); } diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 2e519af79..bffd95e5c 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -315,6 +315,7 @@ function ProfileDetails({ scope={scope} pubkey={pubkey} instanceId={instanceId} + owned={isOwner} /> )} {children} @@ -335,7 +336,7 @@ function ProfileDetails({ scope={scope} communityOrigin={communityOrigin} viewer={viewer} - knownAgent={agentPubkeys.has(pubkey)} + knownAgent={knownAgent} /> )}