diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 8fbd7a3bfb..710b5fc4be 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -6,20 +6,67 @@ 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]), running); - assert.equal(pickProfileAgent([running, stopped]), 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" }); + + // 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", () => { @@ -36,7 +83,10 @@ test("a direct-opened active instance is never redirected to a sibling", () => { status: "running", }; - assert.equal(pickDirectProfileAgent(clicked, [sibling, clicked]), clicked); + assert.equal( + pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), + clicked, + ); }); test("a direct-opened inactive instance redirects to the active sibling", () => { @@ -52,7 +102,7 @@ test("a direct-opened inactive instance redirects to the active sibling", () => }; assert.equal( - pickDirectProfileAgent(historical, [historical, current]), + pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), current, ); }); @@ -70,8 +120,8 @@ test("a direct-opened inactive instance with no active sibling stays put", () => }; assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped]), + pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), clicked, ); - assert.equal(pickDirectProfileAgent(clicked, []), 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 cea746f10c..dc2437c86e 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -7,14 +7,26 @@ 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]; } /** @@ -26,13 +38,15 @@ export function pickProfileAgent(agents: readonly ManagedAgent[]) { * "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. + * 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); + const canonical = pickProfileAgent(personaInstances, isArchived); return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index c6b1821ce1..d0ff2e2738 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 ( ( @@ -265,7 +267,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/bucketPersonaInstances.test.mjs b/desktop/src/features/profile/lib/bucketPersonaInstances.test.mjs new file mode 100644 index 0000000000..396c7a9f7e --- /dev/null +++ b/desktop/src/features/profile/lib/bucketPersonaInstances.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { bucketPersonaInstances } from "./useCanonicalManagedAgentProfile.ts"; + +const LIVE_PK = "b".repeat(64); +const SECOND_LIVE_PK = "c".repeat(64); +const ARCHIVED_PK = "a".repeat(64); + +function agent(overrides = {}) { + return { + name: "Instance", + pubkey: LIVE_PK, + personaId: "persona-1", + status: "stopped", + ...overrides, + }; +} + +test("mixed roster splits archived from live, preserving order", () => { + const archived = agent({ pubkey: ARCHIVED_PK }); + const live = agent({ pubkey: LIVE_PK }); + const secondLive = agent({ pubkey: SECOND_LIVE_PK }); + + const { live: liveBucket, archived: archivedBucket } = bucketPersonaInstances( + [archived, live, secondLive], + (pubkey) => pubkey === ARCHIVED_PK, + ); + + assert.deepEqual(liveBucket, [live, secondLive]); + assert.deepEqual(archivedBucket, [archived]); +}); + +test("an all-archived persona puts every instance in the archived bucket", () => { + const first = agent({ pubkey: ARCHIVED_PK }); + const second = agent({ pubkey: LIVE_PK }); + + const { live, archived } = bucketPersonaInstances( + [first, second], + () => true, + ); + + assert.deepEqual(live, []); + assert.deepEqual(archived, [first, second]); +}); + +test("fail-open while loading keeps every instance live", () => { + // The predicate returns `false` for all pubkeys until the archive snapshot + // loads; nothing is bucketed as archived, so nothing is labeled or hidden. + const archived = agent({ pubkey: ARCHIVED_PK }); + const live = agent({ pubkey: LIVE_PK }); + + const { live: liveBucket, archived: archivedBucket } = bucketPersonaInstances( + [archived, live], + () => false, + ); + + assert.deepEqual(liveBucket, [archived, live]); + assert.deepEqual(archivedBucket, []); +}); 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..072b8ccd23 --- /dev/null +++ b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs @@ -0,0 +1,157 @@ +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], + preferDirectManagedAgent: false, + 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], + preferDirectManagedAgent: false, + 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], + preferDirectManagedAgent: false, + 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: [], + preferDirectManagedAgent: false, + 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], + 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. + 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], + preferDirectManagedAgent: false, + preserveRequestedInstance: false, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, live); +}); + +test("preferDirectManagedAgent 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" }); + const clicked = agent({ + name: "Zulu", + pubkey: HISTORICAL_PK, + status: "running", + }); + + const resolved = resolveCanonicalManagedAgent({ + 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. + const archivedDirect = agent({ pubkey: ARCHIVED_PK, status: "stopped" }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: archivedDirect, + isArchived: (pubkey) => pubkey === ARCHIVED_PK, + personaInstances: [archivedDirect, live], + preferDirectManagedAgent: true, + preserveRequestedInstance: false, + pubkey: ARCHIVED_PK, + }); + + assert.equal(resolved, archivedDirect); +}); diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index 6785640a7f..0393a795ce 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -4,12 +4,83 @@ import { pickDirectProfileAgent, 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. + */ +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; +} + +/** + * Split a persona's instances into live and archived buckets off the same + * archive predicate the selector uses — one policy, no duplication. Fail-open + * is inherited: while the archive snapshot loads `isArchived` returns `false`, + * so every instance lands in `live` and nothing is labeled or hidden. + */ +export function bucketPersonaInstances( + personaInstances: readonly ManagedAgent[], + isArchived: (pubkey: string) => boolean, +): { live: ManagedAgent[]; archived: ManagedAgent[] } { + const live: ManagedAgent[] = []; + const archived: ManagedAgent[] = []; + for (const instance of personaInstances) { + (isArchived(instance.pubkey) ? archived : live).push(instance); + } + return { live, archived }; +} + export function useCanonicalManagedAgentProfile(input: { currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; @@ -53,20 +124,36 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => agent.personaId === linkedPersonaId, ); }, [directManagedAgent, linkedPersonaId, managedAgents]); - const managedAgent = React.useMemo(() => { - if (directManagedAgent) { - if (preserveRequestedInstance) return directManagedAgent; - if (preferDirectManagedAgent) { - return pickDirectProfileAgent(directManagedAgent, personaInstances); - } - } - return pickProfileAgent(personaInstances) ?? directManagedAgent; - }, [ - directManagedAgent, - personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, - ]); + const isArchived = useIsArchivedPredicate(); + const managedAgent = React.useMemo( + () => + resolveCanonicalManagedAgent({ + directManagedAgent, + isArchived, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + pubkey, + }), + [ + directManagedAgent, + isArchived, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + pubkey, + ], + ); + // Split the roster for the Instances list off the same predicate the selector + // uses — see `bucketPersonaInstances` for the fail-open semantics. + const instanceBuckets = React.useMemo( + () => bucketPersonaInstances(personaInstances, isArchived), + [isArchived, personaInstances], + ); - return { linkedPersonaId, managedAgent, personaInstances }; + return { + instanceBuckets, + linkedPersonaId, + managedAgent, + }; } diff --git a/desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs b/desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs new file mode 100644 index 0000000000..3a20330bfa --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs @@ -0,0 +1,149 @@ +/** + * Archived-instances subsection regression: the profile panel's Instances list + * splits live and relay-archived siblings, rendering archived rows under a + * clearly labeled "Archived" header so unarchive stays UI-reachable for + * channel-less agents. The section appears whenever live OR archived instances + * exist, self-omits when neither does, shows only the Archived subsection for + * an all-archived persona, and archived rows keep the deliberate explicit- + * pubkey click that feeds selector matrix rule 3. + * + * Mounts the shipping ProfileInstancesSection (owned by the Runtime tab) and + * drives the real expand toggle rather than reimplementing it. + */ + +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 cleanup; +let fireEvent; +let render; +let screen; +let createElement; +let ProfileInstancesSection; + +const LIVE_PK = "b".repeat(64); +const SECOND_LIVE_PK = "c".repeat(64); +const ARCHIVED_PK = "a".repeat(64); + +function agent(overrides = {}) { + return { + pubkey: LIVE_PK, + name: "Instance", + personaId: "persona-1", + status: "stopped", + ...overrides, + }; +} + +function baseProps(overrides = {}) { + return { + currentPubkey: null, + instances: [], + archivedInstances: [], + onOpenInstance: () => {}, + ...overrides, + }; +} + +function renderSection(props) { + return render(createElement(ProfileInstancesSection, baseProps(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() {}, + }); + + ({ cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ ProfileInstancesSection } = await import("./ProfileInstancesSection.tsx")); +}); + +afterEach(() => cleanup?.()); +after(() => dom.window.close()); + +test("test_archived_and_live_instances_render_archived_subsection", () => { + renderSection({ + instances: [agent({ pubkey: LIVE_PK, name: "Live" })], + archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived one" })], + }); + fireEvent.click(screen.getByTestId("user-profile-instances")); + + assert.equal( + screen.getByTestId("user-profile-instances").textContent, + "2 instances", + ); + assert.ok(screen.getByTestId("user-profile-instances-archived-header")); + assert.ok(screen.getByTestId(`user-profile-instance-${LIVE_PK}`)); + assert.ok(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`)); +}); + +test("test_no_archived_instances_omits_archived_subsection", () => { + renderSection({ + instances: [ + agent({ pubkey: LIVE_PK, name: "Live" }), + agent({ pubkey: SECOND_LIVE_PK, name: "Live two" }), + ], + archivedInstances: [], + }); + fireEvent.click(screen.getByTestId("user-profile-instances")); + + assert.equal(screen.queryByTestId("user-profile-instances-archived"), null); + assert.ok(screen.getByTestId(`user-profile-instance-${LIVE_PK}`)); +}); + +test("test_all_archived_persona_shows_only_archived_subsection", () => { + renderSection({ + instances: [], + archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived only" })], + }); + // Section renders even with an empty live list. + fireEvent.click(screen.getByTestId("user-profile-instances")); + + assert.equal( + screen.getByTestId("user-profile-instances").textContent, + "1 instance", + ); + assert.ok(screen.getByTestId("user-profile-instances-archived-header")); + assert.ok(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`)); +}); + +test("test_no_instances_at_all_omits_instances_section", () => { + renderSection({ instances: [], archivedInstances: [] }); + assert.equal(screen.queryByTestId("user-profile-instances-section"), null); +}); + +test("test_archived_row_click_opens_that_explicit_pubkey", () => { + const opened = []; + renderSection({ + instances: [], + archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived only" })], + onOpenInstance: (pubkey) => opened.push(pubkey), + }); + fireEvent.click(screen.getByTestId("user-profile-instances")); + fireEvent.click(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`)); + + // The archived row keeps the deliberate explicit-pubkey path (unarchive). + assert.deepEqual(opened, [ARCHIVED_PK]); +}); diff --git a/desktop/src/features/profile/ui/ProfileInstancesSection.tsx b/desktop/src/features/profile/ui/ProfileInstancesSection.tsx new file mode 100644 index 0000000000..63c5e546e8 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileInstancesSection.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import { ChevronRight } from "lucide-react"; + +import { ProfileSectionGroup } from "@/features/profile/ui/UserProfilePanelFields"; +import type { ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +function ProfileInstanceRow({ + archived = false, + currentPubkey, + instance, + onOpenInstance, +}: { + archived?: boolean; + currentPubkey: string | null; + instance: ManagedAgent; + onOpenInstance: (pubkey: string) => void; +}) { + const isCurrent = instance.pubkey === currentPubkey; + return ( + + ); +} + +/** + * The persona's managed-agent instances, split into live rows and a labeled + * "Archived" subsection. Archived rows keep the explicit-pubkey click so + * unarchive stays UI-reachable for channel-less agents — the deliberate- + * navigation path (selector matrix rule 3) that lets a click land on the exact + * archived identity. The count reflects both buckets; the section renders only + * when at least one instance (live or archived) exists. + */ +export function ProfileInstancesSection({ + archivedInstances, + currentPubkey, + instances, + onOpenInstance, +}: { + archivedInstances: ManagedAgent[]; + currentPubkey: string | null; + instances: ManagedAgent[]; + onOpenInstance: (pubkey: string) => void; +}) { + const [expanded, setExpanded] = React.useState(false); + const totalCount = instances.length + archivedInstances.length; + if (totalCount === 0) return null; + const instanceCountLabel = `${totalCount} instance${totalCount === 1 ? "" : "s"}`; + + return ( + + + {expanded ? ( + <> + {instances.map((instance) => ( + + ))} + {archivedInstances.length > 0 ? ( +
+

+ Archived +

+ {archivedInstances.map((instance) => ( + + ))} +
+ ) : null} + + ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 7638b0cb36..f02b5aee09 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -189,7 +189,7 @@ export function UserProfilePanel({ ); const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); - const { linkedPersonaId, managedAgent, personaInstances } = + const { instanceBuckets, linkedPersonaId, managedAgent } = useCanonicalManagedAgentProfile({ currentPubkey, managedAgents: managedAgentsQuery.data, @@ -812,7 +812,7 @@ export function UserProfilePanel({ isFollowing={isFollowing} isOwner={viewerIsOwner} isSelf={isSelf} - instances={personaInstances} + instanceBuckets={instanceBuckets} activityAgent={activityAgent} managedAgent={managedAgent} agentInfoFields={agentInfoFields} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index dbe17473f6..0582e53965 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -87,7 +87,7 @@ export type ProfileSummaryViewProps = { isFollowing: boolean; isOwner: boolean | undefined; isSelf: boolean; - instances: ManagedAgent[]; + instanceBuckets: { live: ManagedAgent[]; archived: ManagedAgent[] }; managedAgent: ManagedAgent | undefined; agentInfoFields: ProfileField[]; archiveActions: IdentityArchiveActions; @@ -163,7 +163,7 @@ export function ProfileSummaryView({ isFollowing, isOwner, isSelf, - instances, + instanceBuckets, managedAgent, agentInfoFields, archiveActions, @@ -233,7 +233,8 @@ export function ProfileSummaryView({ (managedAgent !== undefined || runtimeConfigurationFields.length > 0 || runtimeSettingsFields.length > 0 || - instances.length > 0 || + instanceBuckets.live.length > 0 || + instanceBuckets.archived.length > 0 || diagnosticsFields.length > 0 || canOpenAgentLogs); const showDiagnosticsIngress = @@ -535,7 +536,8 @@ export function ProfileSummaryView({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsTrailing} configurationFields={runtimeFields} - instances={instances} + instances={instanceBuckets.live} + archivedInstances={instanceBuckets.archived} modelSettings={ isOwner === true && managedAgent !== undefined ? ( void; -}) { - const [expanded, setExpanded] = React.useState(false); - const instanceCountLabel = `${instances.length} instance${instances.length === 1 ? "" : "s"}`; - - return ( - - - {expanded - ? instances.map((instance) => { - const isCurrent = instance.pubkey === currentPubkey; - return ( - - ); - }) - : null} - - ); -} - function ProfileLiveActivityEmbed({ activeTurns, activityAgent, @@ -765,6 +706,7 @@ function ArchiveStatusTooltip() { export function ProfileRuntimeTabContent({ autoRestartEnabled = false, + archivedInstances, currentPubkey, diagnosticsFields, diagnosticsSummary, @@ -782,6 +724,7 @@ export function ProfileRuntimeTabContent({ }: { /** Whether the per-agent auto-restart toggle is ON. */ autoRestartEnabled?: boolean; + archivedInstances: ManagedAgent[]; currentPubkey: string | null; diagnosticsFields: ProfileField[]; diagnosticsSummary: React.ReactNode; @@ -822,7 +765,7 @@ export function ProfileRuntimeTabContent({ startOnLaunchField !== undefined || showDiagnosticsIngress; const hasConfigurationRows = remainingConfigurationFields.length > 0; - const hasInstances = instances.length > 0; + const hasInstances = instances.length > 0 || archivedInstances.length > 0; if ( statusDiagnosticsFields.length === 0 && @@ -935,6 +878,7 @@ export function ProfileRuntimeTabContent({ {modelSettings} {hasInstances ? (