Skip to content
Merged
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
37 changes: 37 additions & 0 deletions desktop/src/features/agents/lib/agentCardAvatar.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
isAgentCardAvatarLoading,
resolveAgentCardAvatarUrl,
} from "./agentCardAvatar.ts";

test("running agent card prefers the pubkey profile avatar", () => {
assert.equal(
resolveAgentCardAvatarUrl(
"https://relay.example/instance.png",
"https://relay.example/definition.png",
),
"https://relay.example/instance.png",
);
});

test("running agent card falls back to the definition avatar", () => {
assert.equal(
resolveAgentCardAvatarUrl(null, " https://relay.example/definition.png "),
"https://relay.example/definition.png",
);
});

test("running agent card ignores blank avatar values", () => {
assert.equal(resolveAgentCardAvatarUrl(" ", ""), null);
});

test("linked agent actions wait for the authoritative profile avatar", () => {
assert.equal(isAgentCardAvatarLoading(true, true), true);
assert.equal(isAgentCardAvatarLoading(true, false), false);
});

test("unlinked persona actions do not wait for a profile", () => {
assert.equal(isAgentCardAvatarLoading(false, true), false);
});
29 changes: 29 additions & 0 deletions desktop/src/features/agents/lib/agentCardAvatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Resolve the avatar for a running agent card.
*
* The card opens the concrete agent pubkey's profile, so that profile's kind:0
* picture is authoritative. The linked definition remains a fallback while the
* profile is missing or has no picture.
*/
export function resolveAgentCardAvatarUrl(
profileAvatarUrl: string | null | undefined,
personaAvatarUrl: string | null | undefined,
): string | null {
for (const candidate of [profileAvatarUrl, personaAvatarUrl]) {
const trimmed = candidate?.trim();
if (trimmed) return trimmed;
}
return null;
}

/**
* A linked agent's profile is authoritative even when the definition already
* supplies a fallback. Avatar-dependent actions must wait for that profile
* query so they cannot snapshot the fallback before the profile resolves.
*/
export function isAgentCardAvatarLoading(
hasLinkedAgent: boolean,
isProfilePending: boolean,
): boolean {
return hasLinkedAgent && isProfilePending;
}
17 changes: 14 additions & 3 deletions desktop/src/features/agents/lib/personaCatalogRelay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -319,12 +319,20 @@ function localPersona(overrides = {}) {
// The duplicate-add bug: a copy of Alice's entry carries a fresh local UUID, so
// matching by id finds nothing and the catalog offers "Add" again. Only the
// stored catalogSource coordinate links the copy back to the publication.
test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => {
test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => {
const publisherAvatar = "https://relay.example/publisher.png";
const publications = catalogPublicationsFromEvents([
personaEvent({ createdAt: 1, id: "alice-reviewer" }),
personaEvent({
createdAt: 1,
id: "alice-reviewer",
avatarUrl: publisherAvatar,
}),
]);
const copy = localPersona({
id: "a-fresh-uuid",
displayName: "Locally Renamed Reviewer",
avatarUrl: "https://relay.example/local-copy.png",
systemPrompt: "Locally edited instructions.",
catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" },
});

Expand All @@ -334,13 +342,16 @@ test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => {
assert.equal(
personas[0].id,
"a-fresh-uuid",
"the projection must resolve to the existing local copy, not a synthetic id",
"the projection must retain the existing local copy's linkage id",
);
assert.equal(
personas[0].isActive,
true,
"an added foreign entry must read as already selected",
);
assert.equal(personas[0].displayName, "Relay Reviewer");
assert.equal(personas[0].avatarUrl, publisherAvatar);
assert.equal(personas[0].systemPrompt, "Review changes.");
});

test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
Expand Down
12 changes: 9 additions & 3 deletions desktop/src/features/agents/lib/personaCatalogRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,14 @@ function publicationToPersona(
isOwn: boolean,
): CatalogPersona {
const timestamp = new Date(publication.createdAt * 1_000).toISOString();
const basePersona: AgentPersona = localPersona ?? {
id: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`,
// The publication remains authoritative for catalog presentation. An added
// local copy contributes only the linkage id and selected state; merging the
// whole copy would leak local edits (notably its avatar) into the publisher's
// catalog entry.
const basePersona: AgentPersona = {
id:
localPersona?.id ??
`catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`,
displayName: publication.agent.displayName,
avatarUrl: publication.agent.avatarUrl,
systemPrompt: publication.agent.systemPrompt,
Expand All @@ -299,7 +305,7 @@ function publicationToPersona(
provider: publication.agent.provider,
namePool: publication.agent.namePool,
isBuiltIn: false,
isActive: false,
isActive: localPersona?.isActive ?? false,
shared: true,
sourceTeam: null,
envVars: {},
Expand Down
18 changes: 6 additions & 12 deletions desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from "react";
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";

import {
isAgentCardAvatarLoading,
resolveAgentCardAvatarUrl,
} from "@/features/agents/lib/agentCardAvatar";
import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
Expand Down Expand Up @@ -277,7 +281,7 @@ function AgentPersonaCard({
const isActive = agent ? isManagedAgentActive(agent) : false;
const profileQuery = useUserProfileQuery(agent?.pubkey);
const avatarUrl = agent
? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl)
? resolveAgentCardAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl)
: persona.avatarUrl;
const friendlyError = agent
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
Expand All @@ -288,7 +292,7 @@ function AgentPersonaCard({
<AgentIdentityCard
actions={actions?.(
avatarUrl,
Boolean(agent && !persona.avatarUrl && profileQuery.isPending),
isAgentCardAvatarLoading(Boolean(agent), profileQuery.isPending),
)}
ariaLabel={`${title} agent profile`}
avatar={
Expand Down Expand Up @@ -425,16 +429,6 @@ function StandaloneAgentCard({
);
}

function firstAvatarUrl(
...candidates: Array<string | null | undefined>
): string | null {
for (const candidate of candidates) {
const trimmed = candidate?.trim();
if (trimmed) return trimmed;
}
return null;
}

function NewAgentCard({
isPending,
onCreate,
Expand Down
Loading