Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions desktop/src/features/agents/lib/pickProfileAgent.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,65 @@ import test from "node:test";

import { pickProfileAgent } from "./pickProfileAgent.ts";

const NONE_ARCHIVED = () => false;

function agent(overrides = {}) {
return {
name: "Instance",
pubkey: "a".repeat(64),
status: "stopped",
...overrides,
};
}

test("the shared profile target prefers the active persona instance", () => {
const stopped = {
const stopped = agent({
name: "Earlier instance",
pubkey: "a".repeat(64),
status: "stopped",
};
const running = {
});
const running = agent({
name: "Current instance",
pubkey: "b".repeat(64),
status: "running",
};
});

assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
assert.equal(pickProfileAgent([running, stopped], NONE_ARCHIVED), running);
});

test("an archived instance early in file order cannot hijack the target", () => {
const archived = agent({
name: "Archived instance",
pubkey: "a".repeat(64),
status: "running",
});
const live = agent({
name: "Live instance",
pubkey: "b".repeat(64),
status: "stopped",
});
const isArchived = (pubkey) => pubkey === archived.pubkey;

// Archived is active AND first — without the filter it would win the sort.
assert.equal(pickProfileAgent([archived, live], isArchived), live);
assert.equal(pickProfileAgent([live, archived], isArchived), live);
});

test("all instances archived yields undefined for persona-only mode", () => {
const first = agent({ pubkey: "a".repeat(64) });
const second = agent({ pubkey: "b".repeat(64) });

assert.equal(
pickProfileAgent([first, second], () => true),
undefined,
);
});

test("a fail-open predicate keeps every instance eligible while loading", () => {
const stopped = agent({ pubkey: "a".repeat(64), status: "stopped" });
const running = agent({ pubkey: "b".repeat(64), status: "running" });

assert.equal(pickProfileAgent([stopped, running]), running);
assert.equal(pickProfileAgent([running, stopped]), running);
// Fail-open (all false) during the archive-snapshot window: normal ranking.
assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
});
26 changes: 19 additions & 7 deletions desktop/src/features/agents/lib/pickProfileAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@ import type { ManagedAgent } from "@/shared/api/types";
* A persona can have several historical agent instances. Keeping this rule in
* one place prevents an avatar click on an older message from opening a
* different detail surface than the card in the Agents library.
*
* Relay-archived instances are never eligible, so an archived record early in
* file order can't hijack the persona target. Returns `undefined` when every
* instance is archived — the card then renders in persona-only mode. The
* `isArchived` predicate is fail-open (returns `false` while the relay archive
* snapshot loads), so a cold start never briefly picks nothing.
*/
export function pickProfileAgent(agents: readonly ManagedAgent[]) {
return [...agents].sort((left, right) => {
const activeDiff =
Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left));
if (activeDiff !== 0) return activeDiff;
return left.name.localeCompare(right.name);
})[0];
export function pickProfileAgent(
agents: readonly ManagedAgent[],
isArchived: (pubkey: string) => boolean,
) {
return [...agents]
.filter((agent) => !isArchived(agent.pubkey))
.sort((left, right) => {
const activeDiff =
Number(isManagedAgentActive(right)) -
Number(isManagedAgentActive(left));
if (activeDiff !== 0) return activeDiff;
return left.name.localeCompare(right.name);
})[0];
}
25 changes: 14 additions & 11 deletions desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => (
Expand Down Expand Up @@ -264,7 +266,6 @@ function AgentPersonaCard({
const friendlyError = agent
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
: null;
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);

return (
<AgentIdentityCard
Expand Down Expand Up @@ -312,13 +313,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={
Expand Down
Loading