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
20 changes: 9 additions & 11 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { agentIdentityKey } from "@/features/agents/lib/agentIdentity";
import type { Channel, RelayAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";

Expand Down Expand Up @@ -285,16 +286,13 @@ type AgentAutocompleteCandidate = {
personaId?: string | null;
};

function agentIdentityKey<T extends AgentAutocompleteCandidate>(candidate: T) {
if (candidate.isAgent !== true || !candidate.pubkey) {
return null;
}

// Pubkeys—not persona metadata or a display name—are agent identities.
// A persona may be installed more than once, and an owner may intentionally
// create multiple same-named agents. Collapsing either case makes one agent
// impossible to choose from autocomplete.
return `pubkey:${normalizePubkey(candidate.pubkey)}`;
function agentAutocompleteIdentityKey<T extends AgentAutocompleteCandidate>(
candidate: T,
) {
// Only agents coalesce; two humans may legitimately share every other field.
// The identity itself comes from `agentIdentityKey` so this surface and the
// Agents library cannot drift into two different answers for "same agent?".
return candidate.isAgent === true ? agentIdentityKey(candidate) : null;
}

function agentCandidateRank<T extends AgentAutocompleteCandidate>(
Expand Down Expand Up @@ -369,7 +367,7 @@ export function coalesceAgentAutocompleteCandidates<
const indexesByKey = new Map<string, number>();

for (const candidate of candidates) {
const key = agentIdentityKey(candidate);
const key = agentAutocompleteIdentityKey(candidate);
if (!key) {
output.push(candidate);
continue;
Expand Down
138 changes: 138 additions & 0 deletions desktop/src/features/agents/lib/agentIdentity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
agentDisplayGroupKey,
agentIdentityKey,
groupAgentsForDisplay,
} from "./agentIdentity.ts";

const PUBKEY_A = "a".repeat(64);
const PUBKEY_B = "b".repeat(64);

test("agent identity is the pubkey, never persona metadata or a name", () => {
const left = {
pubkey: PUBKEY_A,
name: "Bumble",
personaId: "builtin:bumble",
};
const right = {
pubkey: PUBKEY_B,
name: "Bumble",
personaId: "builtin:bumble",
};

assert.notEqual(agentIdentityKey(left), agentIdentityKey(right));
assert.equal(
agentIdentityKey({ pubkey: ` ${PUBKEY_A.toUpperCase()} ` }),
agentIdentityKey({ pubkey: PUBKEY_A, personaId: "something-else" }),
);
assert.equal(agentIdentityKey({ pubkey: null }), null);
assert.equal(agentIdentityKey({}), null);
});

test("the display group key separates renamed instances of one persona", () => {
const claude = {
pubkey: PUBKEY_A,
name: "Claude",
personaId: "builtin:fizz",
};
const fizz = { pubkey: PUBKEY_B, name: "Fizz", personaId: "builtin:fizz" };

assert.notEqual(agentDisplayGroupKey(claude), agentDisplayGroupKey(fizz));
assert.equal(
agentDisplayGroupKey(claude),
agentDisplayGroupKey({ ...claude, pubkey: PUBKEY_B, name: " claude " }),
);
assert.notEqual(
agentDisplayGroupKey(claude),
agentDisplayGroupKey({ ...claude, personaId: "builtin:honey" }),
);
});

test("a name is folded to NFC, so one fleet does not split on encoding", () => {
// macOS input methods and file systems commonly emit NFD, Windows emits NFC.
// The same name typed on two machines must land on one card.
const precomposed = "José"; // é as U+00E9
const decomposed = "José"; // e + U+0301 combining acute

assert.notEqual(precomposed, decomposed, "the inputs really do differ");
assert.equal(
agentDisplayGroupKey({ personaId: "builtin:fizz", name: precomposed }),
agentDisplayGroupKey({ personaId: "builtin:fizz", name: decomposed }),
);

const groups = groupAgentsForDisplay([
{ pubkey: PUBKEY_A, name: precomposed, personaId: "builtin:fizz" },
{ pubkey: PUBKEY_B, name: decomposed, personaId: "builtin:fizz" },
]);

assert.equal(
groups.length,
1,
"two encodings of one name must not render two identical-looking cards",
);
assert.equal(groups[0].agents.length, 2);
});

test("the group key cannot be forged by a name containing a separator", () => {
// Segments are length-prefixed. With a plain `|` join both of these render
// `persona:a|name:x|name:y`, silently merging two different agents onto one
// card and leaving one of them unopenable.
assert.notEqual(
agentDisplayGroupKey({ personaId: "a", name: "x|name:y" }),
agentDisplayGroupKey({ personaId: "a|name:x", name: "y" }),
);
assert.notEqual(
agentDisplayGroupKey({ personaId: "builtin:fizz", name: "a:b" }),
agentDisplayGroupKey({ personaId: "builtin:fizz:a", name: "b" }),
);
// A separator in a name is still just a name — same input, same key.
assert.equal(
agentDisplayGroupKey({ personaId: "a", name: "x|name:y" }),
agentDisplayGroupKey({ personaId: "a", name: " X|NAME:Y " }),
);
});

test("unnamed instances of one persona share a card — documented, not accidental", () => {
// "", " ", null and undefined all fold to the same empty name, so several
// unnamed instances of one persona collapse onto the persona's card. They
// stay reachable through that card's profile panel, which lists every
// instance behind it. Asserted so a future change to the fold has to decide
// this deliberately rather than discover it.
const groups = groupAgentsForDisplay([
{ pubkey: PUBKEY_A, name: "", personaId: "builtin:fizz" },
{ pubkey: PUBKEY_B, name: " ", personaId: "builtin:fizz" },
{ pubkey: "c".repeat(64), name: null, personaId: "builtin:fizz" },
{ pubkey: "d".repeat(64), name: "Fizz", personaId: "builtin:fizz" },
]);

assert.deepEqual(
groups.map((group) => group.name),
["", "Fizz"],
);
assert.equal(groups[0].agents.length, 3, "no unnamed instance is dropped");
});

test("display grouping keeps every distinct identity and drops repeats", () => {
const agents = [
{ pubkey: PUBKEY_A, name: "Claude", personaId: "builtin:fizz" },
{ pubkey: PUBKEY_B, name: "Fizz", personaId: "builtin:fizz" },
{ pubkey: PUBKEY_A, name: "Claude", personaId: "builtin:fizz" },
];

const groups = groupAgentsForDisplay(agents);

assert.deepEqual(
groups.map((group) => group.name),
["Claude", "Fizz"],
);
assert.deepEqual(
new Set(groups.flatMap((group) => group.agents).map(agentIdentityKey)),
new Set([
agentIdentityKey({ pubkey: PUBKEY_A }),
agentIdentityKey(agents[1]),
]),
);
assert.equal(groups[0].agents.length, 1);
});
110 changes: 110 additions & 0 deletions desktop/src/features/agents/lib/agentIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { normalizePubkey } from "@/shared/lib/pubkey";

/**
* THE definition of agent identity, shared by every surface that answers
* "which agents exist" (@-mention autocomplete, the Agents library, the
* profile panel). Two surfaces that hand-roll this drift apart, and the drift
* is invisible until an agent becomes unreachable on one of them.
*
* Pubkeys—not persona metadata or a display name—are agent identities. A
* persona may be installed more than once, and an owner may intentionally
* create multiple same-named agents. Collapsing either case makes one agent
* impossible to choose from autocomplete, and impossible to manage from the
* Agents library.
*/
export type AgentIdentityInput = { pubkey?: string | null };

export function agentIdentityKey(candidate: AgentIdentityInput): string | null {
const pubkey = candidate.pubkey?.trim();
return pubkey ? `pubkey:${normalizePubkey(pubkey)}` : null;
}

export type AgentDisplayInput = AgentIdentityInput & {
name?: string | null;
personaId?: string | null;
};

/**
* Presentation-only key: which agents may legitimately share ONE card in the
* Agents library. This is NOT an identity — it is a statement about what a
* single label can truthfully stand for. Instances of one persona that all
* carry the same name are interchangeable on a card (the card's profile panel
* lists every instance behind it); an instance the owner renamed is not, and
* must get a card of its own or it disappears from the library.
*
* Every caller must keep the full identity list of a display group reachable —
* grouping may never drop an `agentIdentityKey`.
*/
export function agentDisplayGroupKey(agent: AgentDisplayInput): string {
const personaId = agent.personaId?.trim() ?? "";
// Length-prefixed segments, not a delimiter. A display name is free text and
// may contain any separator we could pick: with a plain `|` join,
// {personaId:"a", name:"x|name:y"} and {personaId:"a|name:x", name:"y"} both
// render `persona:a|name:x|name:y`, so two different agents share one card
// and one of them stops being openable.
const foldedName = foldAgentDisplayName(agent.name);
return `persona:${personaId.length}:${personaId}|name:${foldedName.length}:${foldedName}`;
}

/**
* The one place a display name is folded for comparison. Callers that need a
* name-scoped key of their own (React keys, `data-testid`s) must fold through
* this rather than lowercasing inline, or their key and the group's disagree.
*
* Unicode is normalized to NFC before folding. macOS input methods and file
* systems commonly produce NFD while Windows produces NFC, so without this the
* same name typed on two machines in one fleet folds to two different keys and
* the library renders two cards with visually identical labels — a split the
* owner cannot see, explain, or fix from the UI.
*/
export function foldAgentDisplayName(name: string | null | undefined): string {
return name?.normalize("NFC").trim().toLowerCase() ?? "";
}

export type AgentDisplayGroup<T> = {
key: string;
/** Trimmed display name shared by every member, empty when unnamed. */
name: string;
/** `name` folded for comparison — the group's identity within a persona. */
foldedName: string;
agents: T[];
};

/**
* Split agents into display groups, in first-seen order, dropping repeated
* records of the same identity (the same pubkey read from two sources) but
* never dropping a distinct identity.
*/
export function groupAgentsForDisplay<T extends AgentDisplayInput>(
agents: readonly T[],
): AgentDisplayGroup<T>[] {
const groups: AgentDisplayGroup<T>[] = [];
const groupsByKey = new Map<string, AgentDisplayGroup<T>>();
const seenIdentities = new Set<string>();

for (const agent of agents) {
const identity = agentIdentityKey(agent);
if (identity) {
if (seenIdentities.has(identity)) continue;
seenIdentities.add(identity);
}

const key = agentDisplayGroupKey(agent);
const existing = groupsByKey.get(key);
if (existing) {
existing.agents.push(agent);
continue;
}

const group = {
key,
name: agent.name?.trim() ?? "",
foldedName: foldAgentDisplayName(agent.name),
agents: [agent],
};
groupsByKey.set(key, group);
groups.push(group);
}

return groups;
}
Loading