diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c5bdbc5666..5efa3fe075f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", + "**/exact-key-profile.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..8a9750eccc0 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -200,7 +200,11 @@ with a TypeScript lookup table or an id comparison in a component. agent from Agents, a DM, or a channel must expose the same actions, tabs, fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile - content. + content. Explicit public-key targets are always exact, including stopped, + archived, and relay-only identities. Only explicit persona navigation may + select a representative or offer persona Start; a relay persona link cannot + borrow a local sibling's management controls. See + [the identity contract](../../../../docs/agent-profile-identity.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 710b5fc4be8..9e542be5a90 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "./pickProfileAgent.ts"; +import { pickProfileAgent } from "./pickProfileAgent.ts"; const NONE_ARCHIVED = () => false; @@ -68,60 +65,3 @@ test("a fail-open predicate keeps every instance eligible while loading", () => // Fail-open (all false) during the archive-snapshot window: normal ranking. assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); - -test("a direct-opened active instance is never redirected to a sibling", () => { - // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an - // access edit on Tyler would target the sibling. - const sibling = { - name: "Alpha Sibling", - pubkey: "a".repeat(64), - status: "running", - }; - const clicked = { - name: "Tyler Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), - clicked, - ); -}); - -test("a direct-opened inactive instance redirects to the active sibling", () => { - const historical = { - name: "Earlier Parity Agent", - pubkey: "a".repeat(64), - status: "stopped", - }; - const current = { - name: "Current Parity Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), - current, - ); -}); - -test("a direct-opened inactive instance with no active sibling stays put", () => { - const clicked = { - name: "Only Instance", - pubkey: "a".repeat(64), - status: "stopped", - }; - const otherStopped = { - name: "Another Stopped", - pubkey: "b".repeat(64), - status: "stopped", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), - clicked, - ); - assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked); -}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index dc2437c86ea..19de21f7903 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -5,8 +5,8 @@ import type { ManagedAgent } from "@/shared/api/types"; * Pick the instance that represents a persona throughout the UI. * * A persona can have several historical agent instances. Keeping this rule in - * one place prevents an avatar click on an older message from opening a - * different detail surface than the card in the Agents library. + * one place keeps persona navigation consistent. Explicit pubkey navigation + * never uses this selector: older messages still name their exact author. * * Relay-archived instances are never eligible, so an archived record early in * file order can't hijack the persona target. Returns `undefined` when every @@ -28,25 +28,3 @@ export function pickProfileAgent( return left.name.localeCompare(right.name); })[0]; } - -/** - * Resolve which instance a profile panel opened for `directAgent` should - * show, given every instance of the same persona. - * - * Access edits must target the exact instance the user clicked — resolving a - * running sidebar member to an alphabetically-earlier sibling would let a - * "tighten access" save widen the wrong agent. But when the clicked instance - * is inactive and the persona has an active instance elsewhere (an avatar on - * an old message from a retired instance), redirect to the active one so the - * panel matches the Agents library. The `isArchived` predicate keeps that - * redirect from ever landing on an archived sibling. - */ -export function pickDirectProfileAgent( - directAgent: ManagedAgent, - personaInstances: readonly ManagedAgent[], - isArchived: (pubkey: string) => boolean, -) { - if (isManagedAgentActive(directAgent)) return directAgent; - const canonical = pickProfileAgent(personaInstances, isArchived); - return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; -} diff --git a/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs index 072b8ccd231..888703dd1a0 100644 --- a/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs +++ b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs @@ -26,8 +26,6 @@ test("a persona target with a live sibling resolves to the live instance", () => directManagedAgent: undefined, isArchived: (pubkey) => pubkey === ARCHIVED_PK, personaInstances: [archived, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: undefined, }); @@ -42,8 +40,6 @@ test("a persona target with all instances archived resolves to undefined", () => directManagedAgent: undefined, isArchived: () => true, personaInstances: [first, second], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: undefined, }); @@ -58,8 +54,6 @@ test("an explicit archived pubkey stays exact even when a live sibling exists", directManagedAgent: archivedDirect, isArchived: (pubkey) => pubkey === ARCHIVED_PK, personaInstances: [archivedDirect, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: ARCHIVED_PK, }); @@ -75,15 +69,13 @@ test("an explicit archived pubkey with no managed record resolves to undefined s directManagedAgent: undefined, isArchived: (pubkey) => pubkey === HISTORICAL_PK, personaInstances: [], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: HISTORICAL_PK, }); assert.equal(resolved, undefined); }); -test("a preserved requested instance pins the exact record over canonicalization", () => { +test("an explicit stopped instance pins the exact record over canonicalization", () => { const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); const live = agent({ pubkey: LIVE_PK, status: "running" }); @@ -91,16 +83,14 @@ test("a preserved requested instance pins the exact record over canonicalization directManagedAgent: requested, isArchived: NONE_ARCHIVED, personaInstances: [requested, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: true, pubkey: HISTORICAL_PK, }); assert.equal(resolved, requested); }); -test("a non-archived historical pubkey canonicalizes to the live persona instance", () => { - // Rule 5: #5788 canonicalization is retained for non-archived navigation. +test("a non-archived historical pubkey stays exact, like archived and active keys", () => { + // History is authored by a key, not its current persona representative. const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); const live = agent({ pubkey: LIVE_PK, status: "running" }); @@ -108,15 +98,13 @@ test("a non-archived historical pubkey canonicalizes to the live persona instanc directManagedAgent: requested, isArchived: NONE_ARCHIVED, personaInstances: [requested, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: HISTORICAL_PK, }); - assert.equal(resolved, live); + assert.equal(resolved, requested); }); -test("preferDirectManagedAgent keeps a directly opened active instance exact", () => { +test("explicit navigation keeps a directly opened active instance exact", () => { // The panel's own default: an access edit must target the clicked instance, // not an alphabetically-earlier active sibling. const sibling = agent({ name: "Alpha", pubkey: LIVE_PK, status: "running" }); @@ -130,17 +118,14 @@ test("preferDirectManagedAgent keeps a directly opened active instance exact", ( directManagedAgent: clicked, isArchived: NONE_ARCHIVED, personaInstances: [sibling, clicked], - preferDirectManagedAgent: true, - preserveRequestedInstance: false, pubkey: HISTORICAL_PK, }); assert.equal(resolved, clicked); }); -test("an explicit archived pubkey stays exact even with preferDirectManagedAgent", () => { - // Rule 2 wins over the direct-preference redirect: a deliberately opened - // archived instance must not be redirected away from its unarchive control. +test("an explicit archived pubkey stays exact with a running sibling", () => { + // Archive state does not change what identity an explicit target names. const archivedDirect = agent({ pubkey: ARCHIVED_PK, status: "stopped" }); const live = agent({ pubkey: LIVE_PK, status: "running" }); @@ -148,10 +133,22 @@ test("an explicit archived pubkey stays exact even with preferDirectManagedAgent directManagedAgent: archivedDirect, isArchived: (pubkey) => pubkey === ARCHIVED_PK, personaInstances: [archivedDirect, live], - preferDirectManagedAgent: true, - preserveRequestedInstance: false, pubkey: ARCHIVED_PK, }); assert.equal(resolved, archivedDirect); }); + +for (const instances of [[], [agent({ status: "running" })]]) { + test(`relay-only A never resolves to local sibling B (${instances.length} siblings)`, () => { + assert.equal( + resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: NONE_ARCHIVED, + personaInstances: instances, + pubkey: HISTORICAL_PK, + }), + undefined, + ); + }); +} diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs new file mode 100644 index 00000000000..fdfb5e2d263 --- /dev/null +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("
", { + url: "http://localhost", +}); +let renderHook; +let cleanup; +let createElement; +let QueryClient; +let QueryClientProvider; +let useCanonicalManagedAgentProfile; +const clients = []; +const A = "a".repeat(64); +const B = "b".repeat(64); +const sibling = { + pubkey: B, + personaId: "shared-persona", + status: "running", + name: "Local B", +}; + +before(async () => { + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + ({ renderHook, cleanup } = await import("@testing-library/react")); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ useCanonicalManagedAgentProfile } = await import( + "./useCanonicalManagedAgentProfile.ts" + )); +}); +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); +}); +after(() => dom.window.close()); + +function wrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + client.setQueryData(["identity"], { pubkey: "c".repeat(64) }); + client.setQueryData(["archivedIdentities"], { archived: [] }); + clients.push(client); + return ({ children }) => + createElement(QueryClientProvider, { client }, children); +} + +for (const managedAgents of [[], [sibling]]) { + test(`explicit remote A cannot borrow persona P or its ${managedAgents.length} local instances`, () => { + const { result, rerender } = renderHook( + (props) => useCanonicalManagedAgentProfile(props), + { + wrapper: wrapper(), + initialProps: { managedAgents, personaId: "shared-persona", pubkey: A }, + }, + ); + assert.equal(result.current.managedAgent, undefined); + assert.equal(result.current.linkedPersonaId, undefined); + assert.deepEqual(result.current.instanceBuckets, { + live: [], + archived: [], + }); + // Only deliberately navigating to the persona may choose B or offer Start. + rerender({ managedAgents, personaId: "shared-persona", pubkey: undefined }); + assert.equal(result.current.linkedPersonaId, "shared-persona"); + assert.equal(result.current.managedAgent, managedAgents[0]); + // Returning to the explicit key cannot retain the persona representative. + rerender({ managedAgents, personaId: "shared-persona", pubkey: A }); + assert.equal(result.current.managedAgent, undefined); + assert.equal(result.current.linkedPersonaId, undefined); + }); +} + +test("explicit local instance uses only its own definition link, with normalized key matching", () => { + const { result } = renderHook( + () => + useCanonicalManagedAgentProfile({ + managedAgents: [sibling], + personaId: "unrelated-persona", + pubkey: B.toUpperCase(), + }), + { wrapper: wrapper() }, + ); + assert.equal(result.current.managedAgent, sibling); + assert.equal(result.current.linkedPersonaId, sibling.personaId); +}); diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index 0393a795ce3..70bddcf2964 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -1,66 +1,25 @@ import * as React from "react"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "@/features/agents/lib/pickProfileAgent"; +import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import { useUserProfileQuery } from "@/features/profile/hooks"; -import { ownsAuthorAgent } from "@/features/profile/lib/identity"; -import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; /** - * Resolve the single managed instance a profile surface represents, honouring - * the archive-aware target-provenance rules. Pure so the resolution matrix is - * testable without mounting the panel; the hook supplies the live inputs. - * - * - `preserveRequestedInstance` + a direct match pins that exact record (an - * explicit Runtime → Instances selection). - * - A deliberately requested archived pubkey stays EXACT — exactness beats - * canonicalization iff the requested pubkey is archived — so its archive - * controller can unarchive that identity even when a live sibling exists. - * Returns the managed record when one exists; otherwise `undefined`, so the - * panel falls back to the requested pubkey verbatim (a historical archived - * key with no current managed record still resolves to itself). - * - `preferDirectManagedAgent` (the panel's own default) keeps a directly - * opened active instance exact so an access edit targets it, only redirecting - * an inactive click to a live sibling — see `pickDirectProfileAgent`. - * - Otherwise persona-target and non-archived historical navigation resolve - * through the shared archive-aware selector: all instances archived yields - * `undefined` (persona-only mode), else the canonical live instance. + * An explicit public key always names exactly that identity, whether active, + * stopped, archived, or absent from this device's managed inventory. Only a + * persona target may choose an archive-aware representative. A persona link is + * not an identity alias and never grants local management of a different key. */ export function resolveCanonicalManagedAgent(input: { directManagedAgent: ManagedAgent | undefined; isArchived: (pubkey: string) => boolean; personaInstances: readonly ManagedAgent[]; - preferDirectManagedAgent: boolean; - preserveRequestedInstance: boolean; pubkey: string | undefined; }): ManagedAgent | undefined { - const { - directManagedAgent, - isArchived, - personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, - pubkey, - } = input; - if (preserveRequestedInstance && directManagedAgent) { - return directManagedAgent; - } - if (pubkey && isArchived(pubkey)) { - return directManagedAgent; - } - if (preferDirectManagedAgent && directManagedAgent) { - return pickDirectProfileAgent( - directManagedAgent, - personaInstances, - isArchived, - ); - } - return pickProfileAgent(personaInstances, isArchived) ?? directManagedAgent; + const { directManagedAgent, isArchived, personaInstances, pubkey } = input; + if (pubkey) return directManagedAgent; + return pickProfileAgent(personaInstances, isArchived); } /** @@ -82,21 +41,11 @@ export function bucketPersonaInstances( } export function useCanonicalManagedAgentProfile(input: { - currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; personaId: string | undefined; - preferDirectManagedAgent?: boolean; - preserveRequestedInstance?: boolean; pubkey: string | undefined; }) { - const { - currentPubkey, - managedAgents, - personaId, - preferDirectManagedAgent = false, - preserveRequestedInstance = false, - pubkey, - } = input; + const { managedAgents, personaId, pubkey } = input; const directManagedAgent = React.useMemo(() => { if (!pubkey) return undefined; const target = normalizePubkey(pubkey); @@ -104,18 +53,9 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => normalizePubkey(agent.pubkey) === target, ); }, [managedAgents, pubkey]); - const requestedProfileQuery = useUserProfileQuery(pubkey); - const historicalPersonaId = useOwnedManagedAgentPersonaId({ - agentPubkey: pubkey, - enabled: Boolean( - pubkey && - !directManagedAgent && - ownsAuthorAgent(requestedProfileQuery.data, currentPubkey), - ), - ownerPubkey: currentPubkey, - }); - const linkedPersonaId = - personaId ?? directManagedAgent?.personaId ?? historicalPersonaId; + // Explicit identity targets can use only their own local definition link. + // Relay-only identities must not inherit a local persona's Start/Edit actions. + const linkedPersonaId = pubkey ? directManagedAgent?.personaId : personaId; const personaInstances = React.useMemo(() => { if (!linkedPersonaId) { return directManagedAgent ? [directManagedAgent] : []; @@ -131,18 +71,9 @@ export function useCanonicalManagedAgentProfile(input: { directManagedAgent, isArchived, personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, pubkey, }), - [ - directManagedAgent, - isArchived, - personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, - pubkey, - ], + [directManagedAgent, isArchived, personaInstances, pubkey], ); // Split the roster for the Instances list off the same predicate the selector // uses — see `bucketPersonaInstances` for the fail-open semantics. diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs deleted file mode 100644 index 4c6ec58f212..00000000000 --- a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs +++ /dev/null @@ -1,218 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; -import { JSDOM } from "jsdom"; - -import { relayClient } from "@/shared/api/relayClient"; -import { - KIND_IA_ARCHIVE_REQUEST, - KIND_MANAGED_AGENT, -} from "@/shared/constants/kinds"; -import { - personaIdFromOwnedManagedAgentArchive, - personaIdFromOwnedManagedAgentEvent, - useOwnedManagedAgentPersonaId, -} from "./useOwnedManagedAgentPersonaId.ts"; - -const OWNER_SECRET = new Uint8Array(32); -OWNER_SECRET[31] = 1; -const OTHER_SECRET = new Uint8Array(32); -OTHER_SECRET[31] = 2; -const OWNER = getPublicKey(OWNER_SECRET); -const AGENT = "a".repeat(64); - -function managedAgentEvent({ - agentPubkey = AGENT, - content = JSON.stringify({ persona_id: "persona-reviewer" }), - secret = OWNER_SECRET, -} = {}) { - return finalizeEvent( - { - created_at: 1, - kind: KIND_MANAGED_AGENT, - tags: [["d", agentPubkey]], - content, - }, - secret, - ); -} - -function managedAgentArchive({ - agentPubkey = AGENT, - personaId = "persona-reviewer", - secret = OWNER_SECRET, -} = {}) { - return finalizeEvent( - { - created_at: 2, - kind: KIND_IA_ARCHIVE_REQUEST, - tags: [["p", agentPubkey]], - content: JSON.stringify({ persona_id: personaId }), - }, - secret, - ); -} - -test("resolves an owner-signed historical agent key to its persona", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent(managedAgentEvent(), OWNER, AGENT), - "persona-reviewer", - ); -}); - -test("resolves a deleted historical agent key from its owner-signed archive request", () => { - assert.equal( - personaIdFromOwnedManagedAgentArchive(managedAgentArchive(), OWNER, AGENT), - "persona-reviewer", - ); -}); - -test("rejects archive aliases with the wrong owner or target", () => { - assert.equal( - personaIdFromOwnedManagedAgentArchive( - managedAgentArchive({ secret: OTHER_SECRET }), - OWNER, - AGENT, - ), - null, - ); - assert.equal( - personaIdFromOwnedManagedAgentArchive( - managedAgentArchive({ agentPubkey: "b".repeat(64) }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("rejects a managed-agent event from a different owner", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ secret: OTHER_SECRET }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("rejects a managed-agent event for a different agent key", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ agentPubkey: "b".repeat(64) }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("rejects empty or malformed persona ids", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ content: JSON.stringify({ persona_id: "" }) }), - OWNER, - AGENT, - ), - null, - ); - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ content: "not-json" }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("still resolves the live record when the archive lookup fails", async () => { - const dom = new JSDOM("", { - url: "http://localhost", - }); - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const originalFetchFirstEvent = relayClient.fetchFirstEvent; - relayClient.fetchFirstEvent = async (filter) => { - if (filter.kinds?.includes(KIND_IA_ARCHIVE_REQUEST)) { - throw new Error("archive lookup unavailable"); - } - return managedAgentEvent(); - }; - - try { - const { result } = renderHook(() => - useOwnedManagedAgentPersonaId({ - agentPubkey: AGENT, - enabled: true, - ownerPubkey: OWNER, - }), - ); - await act(async () => { - await Promise.resolve(); - }); - assert.equal(result.current, "persona-reviewer"); - } finally { - cleanup(); - relayClient.fetchFirstEvent = originalFetchFirstEvent; - dom.window.close(); - } -}); - -test("does not expose a persona result for stale lookup inputs", async () => { - const dom = new JSDOM("", { - url: "http://localhost", - }); - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const originalFetchFirstEvent = relayClient.fetchFirstEvent; - relayClient.fetchFirstEvent = async (filter) => - filter.kinds?.includes(KIND_MANAGED_AGENT) ? managedAgentEvent() : null; - const observed = []; - - try { - const { result, rerender, unmount } = renderHook( - (props) => { - const personaId = useOwnedManagedAgentPersonaId(props); - observed.push(personaId); - return personaId; - }, - { - initialProps: { - agentPubkey: AGENT, - enabled: true, - ownerPubkey: OWNER, - }, - }, - ); - await act(async () => { - await Promise.resolve(); - }); - assert.equal(result.current, "persona-reviewer"); - - const switchIndex = observed.length; - rerender({ - agentPubkey: "b".repeat(64), - enabled: false, - ownerPubkey: OWNER, - }); - assert.deepEqual(observed.slice(switchIndex), [null]); - assert.equal(result.current, null); - unmount(); - } finally { - cleanup(); - relayClient.fetchFirstEvent = originalFetchFirstEvent; - dom.window.close(); - } -}); diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts deleted file mode 100644 index 7eef5b21e05..00000000000 --- a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts +++ /dev/null @@ -1,164 +0,0 @@ -import * as React from "react"; -import { verifyEvent } from "nostr-tools/pure"; - -import { relayClient } from "@/shared/api/relayClient"; -import type { RelayEvent } from "@/shared/api/types"; -import { - KIND_IA_ARCHIVE_REQUEST, - KIND_MANAGED_AGENT, -} from "@/shared/constants/kinds"; -import { normalizePubkey } from "@/shared/lib/pubkey"; - -type ManagedAgentEventContent = { - persona_id?: unknown; -}; - -function eventHasValidSignature(event: RelayEvent): boolean { - try { - return verifyEvent({ - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - }); - } catch { - return false; - } -} - -function personaIdFromEventContent(event: RelayEvent): string | null { - try { - const content = JSON.parse(event.content) as ManagedAgentEventContent; - return typeof content.persona_id === "string" && - content.persona_id.trim().length > 0 - ? content.persona_id - : null; - } catch { - return null; - } -} - -export function personaIdFromOwnedManagedAgentEvent( - event: RelayEvent | null, - ownerPubkey: string, - agentPubkey: string, -): string | null { - if (!event || event.kind !== KIND_MANAGED_AGENT) return null; - - const owner = normalizePubkey(ownerPubkey); - const agent = normalizePubkey(agentPubkey); - if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; - if ( - !event.tags.some( - (tag) => tag[0] === "d" && normalizePubkey(tag[1] ?? "") === agent, - ) - ) { - return null; - } - if (!eventHasValidSignature(event)) return null; - - return personaIdFromEventContent(event); -} - -export function personaIdFromOwnedManagedAgentArchive( - event: RelayEvent | null, - ownerPubkey: string, - agentPubkey: string, -): string | null { - if (!event || event.kind !== KIND_IA_ARCHIVE_REQUEST) return null; - - const owner = normalizePubkey(ownerPubkey); - const agent = normalizePubkey(agentPubkey); - if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; - if ( - !event.tags.some( - (tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === agent, - ) - ) { - return null; - } - if (!eventHasValidSignature(event)) return null; - - return personaIdFromEventContent(event); -} - -type PersonaLookupResult = { - key: string; - personaId: string | null; -}; - -/** - * Resolve an owned historical agent key back to its persona. The live - * kind:30177 projection provides the alias before deletion; the owner-signed - * NIP-IA archive request preserves it after the projection is tombstoned. - */ -export function useOwnedManagedAgentPersonaId(input: { - agentPubkey: string | undefined; - enabled: boolean; - ownerPubkey: string | undefined; -}): string | null { - const { agentPubkey, enabled, ownerPubkey } = input; - const [result, setResult] = React.useState