diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index cdb47bccd3..9e542be5a9 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -3,18 +3,65 @@ import test from "node:test"; import { pickProfileAgent } from "./pickProfileAgent.ts"; +const NONE_ARCHIVED = () => false; + +function agent(overrides = {}) { + return { + name: "Instance", + pubkey: "a".repeat(64), + status: "stopped", + ...overrides, + }; +} + test("the shared profile target prefers the active persona instance", () => { - const stopped = { + const stopped = agent({ name: "Earlier instance", pubkey: "a".repeat(64), status: "stopped", - }; - const running = { + }); + const running = agent({ name: "Current instance", pubkey: "b".repeat(64), status: "running", - }; + }); + + assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); + assert.equal(pickProfileAgent([running, stopped], NONE_ARCHIVED), running); +}); + +test("an archived instance early in file order cannot hijack the target", () => { + const archived = agent({ + name: "Archived instance", + pubkey: "a".repeat(64), + status: "running", + }); + const live = agent({ + name: "Live instance", + pubkey: "b".repeat(64), + status: "stopped", + }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + // Archived is active AND first — without the filter it would win the sort. + assert.equal(pickProfileAgent([archived, live], isArchived), live); + assert.equal(pickProfileAgent([live, archived], isArchived), live); +}); + +test("all instances archived yields undefined for persona-only mode", () => { + const first = agent({ pubkey: "a".repeat(64) }); + const second = agent({ pubkey: "b".repeat(64) }); + + assert.equal( + pickProfileAgent([first, second], () => true), + undefined, + ); +}); + +test("a fail-open predicate keeps every instance eligible while loading", () => { + const stopped = agent({ pubkey: "a".repeat(64), status: "stopped" }); + const running = agent({ pubkey: "b".repeat(64), status: "running" }); - assert.equal(pickProfileAgent([stopped, running]), running); - assert.equal(pickProfileAgent([running, stopped]), running); + // Fail-open (all false) during the archive-snapshot window: normal ranking. + assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index c845145495..3c3d042642 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -7,12 +7,24 @@ import type { ManagedAgent } from "@/shared/api/types"; * 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. + * + * Relay-archived instances are never eligible, so an archived record early in + * file order can't hijack the persona target. Returns `undefined` when every + * instance is archived — the card then renders in persona-only mode. The + * `isArchived` predicate is fail-open (returns `false` while the relay archive + * snapshot loads), so a cold start never briefly picks nothing. */ -export function pickProfileAgent(agents: readonly ManagedAgent[]) { - return [...agents].sort((left, right) => { - const activeDiff = - Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left)); - if (activeDiff !== 0) return activeDiff; - return left.name.localeCompare(right.name); - })[0]; +export function pickProfileAgent( + agents: readonly ManagedAgent[], + isArchived: (pubkey: string) => boolean, +) { + return [...agents] + .filter((agent) => !isArchived(agent.pubkey)) + .sort((left, right) => { + const activeDiff = + Number(isManagedAgentActive(right)) - + Number(isManagedAgentActive(left)); + if (activeDiff !== 0) return activeDiff; + return left.name.localeCompare(right.name); + })[0]; } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 47cb78c605..657da1b43d 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -9,6 +9,7 @@ import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModel import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; @@ -94,9 +95,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDeletePersona, } = props; + const isArchived = useIsArchivedPredicate(); const { groups, ungrouped, unknown } = React.useMemo( - () => buildUnifiedGroups(personas, agents), - [personas, agents], + () => buildUnifiedGroups(personas, agents, isArchived), + [personas, agents, isArchived], ); const [collapsed, setCollapsed] = React.useState>(new Set()); function toggle(key: string) { @@ -129,7 +131,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onClick={onOpenCatalog} /> {groups.map((group) => { - const profileAgent = pickProfileAgent(group.agents); + const profileAgent = pickProfileAgent(group.agents, isArchived); return ( ( @@ -264,7 +266,6 @@ function AgentPersonaCard({ const friendlyError = agent ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy : null; - const opensRuntimeTab = Boolean(agent && friendlyError && !isActive); return ( { - if (agent) { - onOpenAgentProfile( - agent.pubkey, - opensRuntimeTab ? { tab: "runtime" } : undefined, - ); - return; - } + // The card's main click always opens the PERSONA target, never an + // explicit pubkey. A pubkey target is durable in the panel, so a pick + // made during the archive-snapshot fail-open window would strand the + // panel on an archived identity after hydration (Carl's cold-hydration + // race). A persona target re-resolves every render through the shared + // archive-aware selector, so it self-corrects to a live sibling — or + // persona-only mode when every instance is archived. Deliberate + // instance navigation and the runtime-error affordance keep their + // explicit-pubkey path via the avatar control below. onOpenPersonaProfile(persona); }} statusBadge={ diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs new file mode 100644 index 0000000000..690a921040 --- /dev/null +++ b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs @@ -0,0 +1,295 @@ +/** + * Rule 1 regression: the persona card's MAIN click records a PERSONA target, + * never an explicit pubkey — even during the archive-snapshot fail-open window, + * when pickProfileAgent transiently selects an archived sibling. + * + * Why a mounted render test rather than a pure resolver test: + * resolveCanonicalManagedAgent (unit-tested separately) proves a persona + * target self-corrects to the live sibling after hydration — but it assumes + * the card emits a persona target. The defect being closed is the card + * emitting a durable *pubkey* target that survives hydration. Only mounting + * the real card and firing its main click catches a mutation that reverts + * onClick back to onOpenAgentProfile(agent.pubkey). AgentPersonaCard is + * module-local, so the whole section is mounted. + * + * Fail-open is reproduced faithfully: the list_archived_identities IPC call + * never settles, so useIsArchivedPredicate returns all-live at click time and + * pickProfileAgent selects the archived-first sibling — exactly the transient + * window the durable pubkey target used to strand the panel on. + */ + +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", +}); + +// Track every client so afterEach can drop cached queries. A query left pending +// (the fail-open archive snapshot) plus react-query's default gcTime schedules +// timers that outlive the test and stall the shared `pnpm test` process. +const clients = []; + +let act; +let cleanup; +let fireEvent; +let render; +let screen; +let createElement; +let QueryClient; +let QueryClientProvider; +let UnifiedAgentsSection; + +const ipcHandlers = new Map(); + +const SELF_PK = "c".repeat(64); +const ARCHIVED_PK = "a".repeat(64); +const LIVE_PK = "b".repeat(64); + +function agent(overrides = {}) { + return { + pubkey: LIVE_PK, + name: "Instance", + personaId: "persona-1", + status: "stopped", + model: null, + modelSource: "global", + lastError: null, + lastErrorCode: null, + needsRestart: false, + personaOrphaned: false, + ...overrides, + }; +} + +function persona(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz Prime", + avatarUrl: null, + model: null, + isBuiltIn: false, + sourceTeam: null, + ...overrides, + }; +} + +function baseProps(overrides = {}) { + return { + defaultModel: "gpt-x", + actionErrorMessage: null, + actionNoticeMessage: null, + agents: [], + agentsError: null, + isActionPending: false, + isAgentsLoading: false, + restartingAgentPubkey: null, + startingAgentPubkey: null, + startingPersonaIds: new Set(), + onOpenAgentProfile: () => {}, + onOpenPersonaProfile: () => {}, + onRestartAgent: () => {}, + onStartAgent: () => {}, + onStartPersona: () => {}, + personas: [], + personasError: null, + personaFeedbackErrorMessage: null, + personaFeedbackNoticeMessage: null, + isPersonasLoading: false, + isPersonasPending: false, + onOpenCatalog: () => {}, + onDuplicatePersona: () => {}, + onEditPersona: () => {}, + onSharePersona: () => {}, + onDeactivatePersona: () => {}, + onDeletePersona: () => {}, + ...overrides, + }; +} + +function renderSection(props) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + clients.push(client); + return render( + createElement( + QueryClientProvider, + { client }, + createElement(UnifiedAgentsSection, props), + ), + ); +} + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + + ({ act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ UnifiedAgentsSection } = await import("./UnifiedAgentsSection.tsx")); +}); + +afterEach(() => { + cleanup?.(); + for (const client of clients.splice(0)) { + client.cancelQueries(); + client.clear(); + } + ipcHandlers.clear(); +}); + +after(() => dom.window.close()); + +function installFailOpenIpc() { + ipcHandlers.set("get_identity", () => + Promise.resolve({ pubkey: SELF_PK, display_name: "Me" }), + ); + // Never resolves: the archive snapshot stays loading, so the predicate is + // fail-open (treats every identity as live) for the whole test. + ipcHandlers.set("list_archived_identities", () => new Promise(() => {})); + ipcHandlers.set("get_user_profile", () => + Promise.resolve({ + pubkey: LIVE_PK, + display_name: null, + avatar_url: null, + about: null, + nip05_handle: null, + owner_pubkey: null, + }), + ); +} + +test("persona card main click records a persona target, never an explicit pubkey", async () => { + installFailOpenIpc(); + + let recordedPersona; + const onOpenAgentProfile = () => { + throw new Error("card main click must not open an explicit pubkey target"); + }; + const onOpenPersonaProfile = (persona) => { + recordedPersona = persona; + }; + + // Archived sibling sorts first by name, so under fail-open pickProfileAgent + // selects it — the card displays the archived identity at click time. A + // durable pubkey target would strand the panel there after hydration. + const agents = [ + agent({ pubkey: ARCHIVED_PK, name: "Archived Sibling" }), + agent({ pubkey: LIVE_PK, name: "Zed Sibling" }), + ]; + + await act(async () => { + renderSection( + baseProps({ + agents, + personas: [persona()], + onOpenAgentProfile, + onOpenPersonaProfile, + }), + ); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Fizz Prime agent profile" }), + ); + + assert.ok(recordedPersona, "the click must record a persona target"); + assert.equal(recordedPersona.id, "persona-1"); +}); + +test("persona card main click records a persona target even for a stopped errored agent", async () => { + installFailOpenIpc(); + + let recordedPersona; + await act(async () => { + renderSection( + baseProps({ + agents: [ + agent({ + pubkey: LIVE_PK, + name: "Errored", + status: "stopped", + lastError: "boom", + }), + ], + personas: [persona()], + onOpenAgentProfile: () => { + throw new Error("main click must not open an explicit pubkey target"); + }, + onOpenPersonaProfile: (persona) => { + recordedPersona = persona; + }, + }), + ); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Fizz Prime agent profile" }), + ); + + assert.equal(recordedPersona?.id, "persona-1"); +}); + +test("errored avatar affordance still opens the explicit pubkey on the runtime tab", async () => { + installFailOpenIpc(); + + const opened = []; + await act(async () => { + renderSection( + baseProps({ + agents: [ + agent({ + pubkey: LIVE_PK, + name: "Errored", + status: "stopped", + lastError: "boom", + }), + ], + personas: [persona()], + onOpenAgentProfile: (pubkey, options) => { + opened.push({ pubkey, options }); + }, + onOpenPersonaProfile: () => { + throw new Error("the error affordance must open the explicit pubkey"); + }, + }), + ); + }); + + // The error badge is the deliberate explicit-pubkey path preserved for + // manage/diagnose access; it is the reserved instance/error navigation that + // rule 1 keeps valid, unchanged by the main-click fix. + fireEvent.click(screen.getByTestId(`agent-runtime-error-${LIVE_PK}`)); + + assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]); +}); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs new file mode 100644 index 0000000000..b3ade7f229 --- /dev/null +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildUnifiedGroups } from "./unifiedAgentGroups.ts"; + +const NONE_ARCHIVED = () => false; + +function agent(overrides = {}) { + return { + name: "Agent", + pubkey: "a".repeat(64), + personaId: null, + status: "stopped", + ...overrides, + }; +} + +function persona(overrides = {}) { + return { id: "persona-1", displayName: "Persona", ...overrides }; +} + +test("archived standalone custom agents are omitted while live peers remain", () => { + const archived = agent({ pubkey: "a".repeat(64), personaId: null }); + const live = agent({ pubkey: "b".repeat(64), personaId: null }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + const { ungrouped } = buildUnifiedGroups([], [archived, live], isArchived); + + assert.deepEqual( + ungrouped.map((agent) => agent.pubkey), + [live.pubkey], + ); +}); + +test("archived unknown-persona agents are omitted while live peers remain", () => { + const archived = agent({ pubkey: "a".repeat(64), personaId: "orphan" }); + const live = agent({ pubkey: "b".repeat(64), personaId: "orphan" }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + // No persona matches "orphan", so both land in the unknown bucket. + const { unknown } = buildUnifiedGroups([], [archived, live], isArchived); + + assert.deepEqual( + unknown.map((agent) => agent.pubkey), + [live.pubkey], + ); +}); + +test("matched persona groups keep their full instance list including archived", () => { + const archived = agent({ pubkey: "a".repeat(64), personaId: "persona-1" }); + const live = agent({ pubkey: "b".repeat(64), personaId: "persona-1" }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + // The card resolves its own target via pickProfileAgent; the group keeps the + // archived record so an all-archived persona still forms a card in + // persona-only mode rather than vanishing from the library. + const { groups } = buildUnifiedGroups( + [persona()], + [archived, live], + isArchived, + ); + + assert.equal(groups.length, 1); + assert.deepEqual( + groups[0].agents.map((agent) => agent.pubkey).sort(), + [archived.pubkey, live.pubkey].sort(), + ); +}); + +test("a fail-open predicate keeps every standalone agent discoverable", () => { + const first = agent({ pubkey: "a".repeat(64), personaId: null }); + const second = agent({ pubkey: "b".repeat(64), personaId: null }); + + const { ungrouped } = buildUnifiedGroups([], [first, second], NONE_ARCHIVED); + + assert.equal(ungrouped.length, 2); +}); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.ts b/desktop/src/features/agents/ui/unifiedAgentGroups.ts index 60c44f9292..2ddf34d840 100644 --- a/desktop/src/features/agents/ui/unifiedAgentGroups.ts +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.ts @@ -2,16 +2,28 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; +/** + * Group managed agents under their personas for the Agents library. + * + * Archived instances are dropped from the standalone `ungrouped` (custom + * agents) and `unknown` buckets so a relay-archived identity never shows as a + * clickable library card of its own. Matched persona groups keep their full + * instance list — the persona card resolves its own target through + * `pickProfileAgent`, which applies the same `isArchived` filter and falls back + * to persona-only mode when every instance is archived. `isArchived` is + * fail-open (returns `false` while the relay archive snapshot loads). + */ export function buildUnifiedGroups( personas: AgentPersona[], agents: ManagedAgent[], + isArchived: (pubkey: string) => boolean, ) { const byPersonaId = new Map(); const ungrouped: ManagedAgent[] = []; for (const agent of agents) { if (!agent.personaId) { - ungrouped.push(agent); + if (!isArchived(agent.pubkey)) ungrouped.push(agent); } else { const list = byPersonaId.get(agent.personaId) ?? []; list.push(agent); @@ -27,7 +39,9 @@ export function buildUnifiedGroups( const unknown: ManagedAgent[] = []; for (const [id, list] of byPersonaId) { - if (!matched.has(id)) unknown.push(...list); + if (!matched.has(id)) { + unknown.push(...list.filter((agent) => !isArchived(agent.pubkey))); + } } return { groups, ungrouped, unknown }; diff --git a/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs new file mode 100644 index 0000000000..f93c224933 --- /dev/null +++ b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCanonicalManagedAgent } from "./useCanonicalManagedAgentProfile.ts"; + +const LIVE_PK = "b".repeat(64); +const ARCHIVED_PK = "a".repeat(64); +const HISTORICAL_PK = "c".repeat(64); +const NONE_ARCHIVED = () => false; + +function agent(overrides = {}) { + return { + name: "Instance", + pubkey: LIVE_PK, + personaId: "persona-1", + status: "stopped", + ...overrides, + }; +} + +test("a persona target with a live sibling resolves to the live instance", () => { + const archived = agent({ pubkey: ARCHIVED_PK, status: "running" }); + const live = agent({ pubkey: LIVE_PK, status: "stopped" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: (pubkey) => pubkey === ARCHIVED_PK, + personaInstances: [archived, live], + preserveRequestedInstance: false, + pubkey: undefined, + }); + + assert.equal(resolved, live); +}); + +test("a persona target with all instances archived resolves to undefined", () => { + const first = agent({ pubkey: ARCHIVED_PK }); + const second = agent({ pubkey: HISTORICAL_PK }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: () => true, + personaInstances: [first, second], + preserveRequestedInstance: false, + pubkey: undefined, + }); + + assert.equal(resolved, undefined); +}); + +test("an explicit archived pubkey stays exact even when a live sibling exists", () => { + const archivedDirect = agent({ pubkey: ARCHIVED_PK }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: archivedDirect, + isArchived: (pubkey) => pubkey === ARCHIVED_PK, + personaInstances: [archivedDirect, live], + preserveRequestedInstance: false, + pubkey: ARCHIVED_PK, + }); + + // Without the exactness short-circuit the selector would drop the archived + // record and return `live`, stranding the unarchive controller. + assert.equal(resolved, archivedDirect); +}); + +test("an explicit archived pubkey with no managed record resolves to undefined so the panel keeps the requested key", () => { + // A historical archived pubkey with no current managed record: directManaged + // is undefined, and the panel falls back to the requested pubkey verbatim. + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: (pubkey) => pubkey === HISTORICAL_PK, + personaInstances: [], + preserveRequestedInstance: false, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, undefined); +}); + +test("a preserved requested instance pins the exact record over canonicalization", () => { + const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: requested, + isArchived: NONE_ARCHIVED, + personaInstances: [requested, live], + 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. + const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: requested, + isArchived: NONE_ARCHIVED, + personaInstances: [requested, live], + preserveRequestedInstance: false, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, live); +}); diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index e900e123f5..6760f1a232 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -1,12 +1,53 @@ import * as React from "react"; 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). + * - 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. + */ +export function resolveCanonicalManagedAgent(input: { + directManagedAgent: ManagedAgent | undefined; + isArchived: (pubkey: string) => boolean; + personaInstances: readonly ManagedAgent[]; + preserveRequestedInstance: boolean; + pubkey: string | undefined; +}): ManagedAgent | undefined { + const { + directManagedAgent, + isArchived, + personaInstances, + preserveRequestedInstance, + pubkey, + } = input; + if (preserveRequestedInstance && directManagedAgent) { + return directManagedAgent; + } + if (pubkey && isArchived(pubkey)) { + return directManagedAgent; + } + return pickProfileAgent(personaInstances, isArchived) ?? directManagedAgent; +} + export function useCanonicalManagedAgentProfile(input: { currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; @@ -48,12 +89,23 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => agent.personaId === linkedPersonaId, ); }, [directManagedAgent, linkedPersonaId, managedAgents]); + const isArchived = useIsArchivedPredicate(); const managedAgent = React.useMemo( () => - preserveRequestedInstance && directManagedAgent - ? directManagedAgent - : (pickProfileAgent(personaInstances) ?? directManagedAgent), - [directManagedAgent, personaInstances, preserveRequestedInstance], + resolveCanonicalManagedAgent({ + directManagedAgent, + isArchived, + personaInstances, + preserveRequestedInstance, + pubkey, + }), + [ + directManagedAgent, + isArchived, + personaInstances, + preserveRequestedInstance, + pubkey, + ], ); return { linkedPersonaId, managedAgent, personaInstances };