fix(desktop): resolve agent profiles through one archive-aware selector (#5706)

Agent profiles resolve through one shared selector (`pickProfileAgent`)
at every entry point — the persona card, the profile panel, and library
grouping. That selector ranked instances only by active/name, with no
archive awareness, so a relay-archived instance early in file order
could hijack the persona card and the profile panel. The persona card
also recorded a durable pubkey target, which could strand the panel on
an archived identity when the click landed during the archive-snapshot
fail-open window. The profile panel's Runtime → Instances roster had the
same blind spot: it rendered every persona instance raw, so archived
instances appeared mixed in with live ones as if active.

This makes the shared resolution path archive-aware via the existing
fail-open `useIsArchivedPredicate`:

- `pickProfileAgent` filters archived instances before ranking and
returns `undefined` when every instance is archived (persona-only mode).
- `buildUnifiedGroups` drops archived agents from the standalone `Custom
agents` and `Unknown agents` buckets; matched persona groups keep their
full list and rely on the selector's persona-only fallback.
- `useCanonicalManagedAgentProfile` resolves through a pure
`resolveCanonicalManagedAgent` helper that applies the target-provenance
rules: a deliberately requested archived pubkey stays exact (so its
archive controller can unarchive it, even when a live sibling exists),
`preserveRequestedInstance` still pins a Runtime → Instances selection,
and non-archived historical navigation keeps its canonicalization.
- The persona card's main click records a persona target that
re-resolves every render, so it self-corrects to a live sibling after
hydration. Deliberate instance navigation and the runtime-error
affordance keep their explicit-pubkey path.
- The Runtime → Instances roster (`ProfileInstancesSection`) buckets
instances off the same predicate via `bucketPersonaInstances`: live rows
render as before, and archived rows move under a labeled `Archived`
subsection. The instance count reflects both buckets, and archived rows
keep their explicit-pubkey click so unarchive stays UI-reachable (the
deliberate-navigation path above).

The predicate is fail-open (treats every identity as live while the
relay archive snapshot loads) and self-exempt, so a cold start never
hides an identity and a user is never folded from their own client.
While the snapshot is loading, every instance renders in the live list —
nothing hidden, nothing labeled.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-17 13:46:25 -04:00
committed by GitHub
parent 54f11219ef
commit d12d825778
14 changed files with 1087 additions and 114 deletions

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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<Set<string>>(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 (
<AgentPersonaCard
actions={(effectiveAvatarUrl, isEffectiveAvatarLoading) => (
@@ -265,7 +267,6 @@ function AgentPersonaCard({
const friendlyError = agent
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
: null;
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
return (
<AgentIdentityCard
@@ -313,13 +314,15 @@ function AgentPersonaCard({
label={title}
modelLabel={modelLabel}
onClick={() => {
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={

View File

@@ -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("<!doctype html><html><body></body></html>", {
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" } }]);
});

View File

@@ -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);
});

View File

@@ -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<string, ManagedAgent[]>();
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 };

View File

@@ -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, []);
});

View File

@@ -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);
});

View File

@@ -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,
};
}

View File

@@ -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("<!doctype html><html><body></body></html>", {
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]);
});

View File

@@ -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 (
<button
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
data-testid={`user-profile-instance-${instance.pubkey}`}
onClick={() => onOpenInstance(instance.pubkey)}
type="button"
>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{instance.name}
</span>
<span className="text-xs capitalize text-muted-foreground">
{archived
? "Archived"
: isCurrent
? "Current"
: instance.status.replace("_", " ")}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
/**
* 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 (
<ProfileSectionGroup
testId="user-profile-instances-section"
title="Instances"
>
<button
aria-expanded={expanded}
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
data-testid="user-profile-instances"
onClick={() => setExpanded((value) => !value)}
type="button"
>
<span className="min-w-0 flex-1 text-sm font-medium">
{instanceCountLabel}
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
</button>
{expanded ? (
<>
{instances.map((instance) => (
<ProfileInstanceRow
currentPubkey={currentPubkey}
instance={instance}
key={instance.pubkey}
onOpenInstance={onOpenInstance}
/>
))}
{archivedInstances.length > 0 ? (
<div data-testid="user-profile-instances-archived">
<p
className="px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
data-testid="user-profile-instances-archived-header"
>
Archived
</p>
{archivedInstances.map((instance) => (
<ProfileInstanceRow
archived
currentPubkey={currentPubkey}
instance={instance}
key={instance.pubkey}
onOpenInstance={onOpenInstance}
/>
))}
</div>
) : null}
</>
) : null}
</ProfileSectionGroup>
);
}

View File

@@ -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}

View File

@@ -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 ? (
<AgentConfigPanel

View File

@@ -26,6 +26,7 @@ import {
useProfileActivityFeedScope,
} from "@/features/profile/lib/profileActivityFeedScope";
import { UserProfileAgentManagementRows } from "@/features/profile/ui/UserProfileAgentManagementRows";
import { ProfileInstancesSection } from "@/features/profile/ui/ProfileInstancesSection";
import {
type ProfileField,
ProfileFieldRows,
@@ -319,66 +320,6 @@ export function ProfileInfoTabContent({
);
}
function ProfileInstancesSection({
currentPubkey,
instances,
onOpenInstance,
}: {
currentPubkey: string | null;
instances: ManagedAgent[];
onOpenInstance: (pubkey: string) => void;
}) {
const [expanded, setExpanded] = React.useState(false);
const instanceCountLabel = `${instances.length} instance${instances.length === 1 ? "" : "s"}`;
return (
<ProfileSectionGroup
testId="user-profile-instances-section"
title="Instances"
>
<button
aria-expanded={expanded}
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
data-testid="user-profile-instances"
onClick={() => setExpanded((value) => !value)}
type="button"
>
<span className="min-w-0 flex-1 text-sm font-medium">
{instanceCountLabel}
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
</button>
{expanded
? instances.map((instance) => {
const isCurrent = instance.pubkey === currentPubkey;
return (
<button
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
data-testid={`user-profile-instance-${instance.pubkey}`}
key={instance.pubkey}
onClick={() => onOpenInstance(instance.pubkey)}
type="button"
>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{instance.name}
</span>
<span className="text-xs capitalize text-muted-foreground">
{isCurrent ? "Current" : instance.status.replace("_", " ")}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
})
: null}
</ProfileSectionGroup>
);
}
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 ? (
<ProfileInstancesSection
archivedInstances={archivedInstances}
currentPubkey={currentPubkey}
instances={instances}
onOpenInstance={onOpenInstance}