;
+ ].filter(Boolean) as Array<{
+ label: string;
+ value: string;
+ multiline?: boolean;
+ }>;
if (previousPersonaAvatarValue !== personaAvatarValue) {
setPreviousPersonaAvatarValue(personaAvatarValue);
@@ -274,7 +287,7 @@ export function AgentDetailPage({
className={AVATAR_CUSTOMIZE_LABEL_CLASS}
aria-hidden="true"
>
- {t("editor.changeAvatar")}
+ {t("builderRail.changeAvatar")}
>
) : null}
diff --git a/src/features/agents/ui/AgentIdentityRail.tsx b/src/features/agents/ui/AgentIdentityRail.tsx
index ad3d71e8c..84ce205c8 100644
--- a/src/features/agents/ui/AgentIdentityRail.tsx
+++ b/src/features/agents/ui/AgentIdentityRail.tsx
@@ -4,7 +4,10 @@ import { cn } from "@/shared/lib/cn";
interface AgentIdentityMetadataItem {
label: string;
value: string;
+ /** Breaks anywhere, for unbroken strings like file paths. */
wrap?: boolean;
+ /** Wraps at word boundaries, for real prose like a description. */
+ multiline?: boolean;
}
interface AgentIdentityRailProps {
@@ -62,7 +65,11 @@ export function AgentIdentityRail({
{item.value}
diff --git a/src/features/agents/ui/PersonaCard.tsx b/src/features/agents/ui/PersonaCard.tsx
index e63bd85d5..f59d927e5 100644
--- a/src/features/agents/ui/PersonaCard.tsx
+++ b/src/features/agents/ui/PersonaCard.tsx
@@ -24,6 +24,7 @@ import type { Persona } from "@/shared/types/agents";
import {
canDeletePersona,
canEditPersona,
+ getRealPersonaDescription,
} from "@/features/agents/lib/personaPresentation";
import { resolveAgentIcon } from "@/features/agents/lib/resolveAgentIcon";
import { getAgentAvatarTransitionName } from "@/features/agents/lib/agentViewTransitions";
@@ -254,7 +255,7 @@ export const PersonaCard = memo(function PersonaCard({
- {persona.systemPrompt}
+ {getRealPersonaDescription(persona) ?? persona.systemPrompt}
diff --git a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx
index bc70be2fa..6af284ef4 100644
--- a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx
+++ b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx
@@ -22,6 +22,7 @@ vi.mock("@/features/agents/lib/agentBuilderSession", () => ({
name === "Untitled agent" || name.startsWith("Untitled agent "),
PLACEHOLDER_AGENT_NAME: "Untitled agent",
PLACEHOLDER_AGENT_BODY: "Draft in progress.",
+ PLACEHOLDER_AGENT_DESCRIPTION: "Draft",
}));
vi.mock("@/features/agents/hooks/useAvatarLibrary", () => ({
@@ -211,6 +212,35 @@ describe("AgentBuilderRail", () => {
expect(update).toHaveBeenCalledWith({ content: "Be snarky." });
});
+ it("calls update() when the description field changes", () => {
+ const { update } = mockHook();
+ renderWithProviders(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText(/description/i), {
+ target: { value: "Catches bugs before you ship them." },
+ });
+ expect(update).toHaveBeenCalledWith({
+ description: "Catches bugs before you ship them.",
+ });
+ });
+
+ it("treats the placeholder draft description as empty in the field", () => {
+ mockHook({ data: { ...baseSource, description: "Draft" } });
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByLabelText(/description/i)).toHaveValue("");
+ });
+
it("renders the placeholder draft body as muted placeholder text", () => {
mockHook();
renderWithProviders(
diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx
index c68326e80..65ca2f5ae 100644
--- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx
+++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx
@@ -73,6 +73,14 @@ vi.mock("@/shared/api/agents", () => ({
readAgentSourceFile: vi.fn().mockResolvedValue(mockDraftSource),
updatePersonaSource: vi.fn().mockResolvedValue(mockDraftSource),
deletePersonaSource: vi.fn().mockResolvedValue(undefined),
+ isPlaceholderAgentDescription: (description: string | undefined | null) => {
+ const trimmed = description?.trim().toLowerCase();
+ return !trimmed || trimmed === "agent" || trimmed === "draft";
+ },
+ hasRealAgentDescription: (description: string | undefined | null) => {
+ const trimmed = description?.trim().toLowerCase();
+ return Boolean(trimmed) && trimmed !== "agent" && trimmed !== "draft";
+ },
}));
vi.mock("@/shared/api/system", () => ({
@@ -373,6 +381,29 @@ describe("AgentsView entry points", () => {
expect(onActivePersonaIdChange).toHaveBeenCalledWith(null, undefined);
});
+ it("shows the agent's description on the detail page, next to provider and model", () => {
+ useAgentStore.setState({
+ personas: [
+ { ...persona, sourceDescription: "Reviews your code carefully." },
+ ],
+ });
+
+ render();
+
+ expect(screen.getByText("view.description")).toBeInTheDocument();
+ expect(
+ screen.getByText("Reviews your code carefully."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows no description row on the detail page when there's no real description", () => {
+ useAgentStore.setState({ personas: [persona] });
+
+ render();
+
+ expect(screen.queryByText("view.description")).not.toBeInTheDocument();
+ });
+
it("shows and activates the avatar customization affordance", async () => {
useAgentStore.setState({ personas: [persona] });
const user = userEvent.setup();
@@ -382,7 +413,7 @@ describe("AgentsView entry points", () => {
const customizeAvatar = screen.getByRole("button", {
name: "editor.customizeAvatar",
});
- expect(screen.getByText("editor.changeAvatar")).toBeInTheDocument();
+ expect(screen.getByText("builderRail.changeAvatar")).toBeInTheDocument();
customizeAvatar.focus();
expect(customizeAvatar).toHaveFocus();
await user.keyboard("{Enter}");
diff --git a/src/features/agents/ui/__tests__/PersonaCard.test.tsx b/src/features/agents/ui/__tests__/PersonaCard.test.tsx
index 23650bcfb..475c100db 100644
--- a/src/features/agents/ui/__tests__/PersonaCard.test.tsx
+++ b/src/features/agents/ui/__tests__/PersonaCard.test.tsx
@@ -146,10 +146,42 @@ describe("PersonaCard", () => {
expect(screen.queryByText(/claude-sonnet/i)).not.toBeInTheDocument();
});
- it("shows system prompt preview", () => {
+ it("shows the agent's description, not its instructions", () => {
render(
,
+ );
+ expect(
+ screen.getByText("Reviews your code and catches bugs."),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText("You are a coding assistant."),
+ ).not.toBeInTheDocument();
+ });
+
+ it("falls back to instructions when there's no real description", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("You are a coding assistant.")).toBeInTheDocument();
+ });
+
+ it("falls back to instructions when the description is a placeholder", () => {
+ render(
+ ,
);
expect(screen.getByText("You are a coding assistant.")).toBeInTheDocument();
diff --git a/src/features/agents/ui/share-card/agentShareCard.ts b/src/features/agents/ui/share-card/agentShareCard.ts
index d7b5939c7..ef8f43943 100644
--- a/src/features/agents/ui/share-card/agentShareCard.ts
+++ b/src/features/agents/ui/share-card/agentShareCard.ts
@@ -2,6 +2,7 @@ import cardFoil from "@/features/agents/assets/share-card/card-foil.png";
import berdCardLogo from "@/features/agents/assets/share-card/berd-card-logo.svg";
import type { ResolvedAvatarMedia } from "@/shared/avatars/catalog";
import type { Persona } from "@/shared/types/agents";
+import { getRealPersonaDescription } from "@/features/agents/lib/personaPresentation";
import {
fallbackAgentCardColor,
sampleAgentAvatarColor,
@@ -24,13 +25,7 @@ export function getAgentShareCardBase(_personaId: string): string {
}
export function getAgentShareDescription(persona: Persona): string {
- const sourceDescription = persona.sourceDescription?.trim();
- const candidate =
- sourceDescription && sourceDescription.toLowerCase() !== "agent"
- ? sourceDescription
- : persona.systemPrompt.trim();
-
- return candidate;
+ return getRealPersonaDescription(persona) ?? persona.systemPrompt.trim();
}
export function getAgentShareFilename(name: string): string {
diff --git a/src/shared/api/__tests__/agents.test.ts b/src/shared/api/__tests__/agents.test.ts
index af1dad3d3..59b3c2643 100644
--- a/src/shared/api/__tests__/agents.test.ts
+++ b/src/shared/api/__tests__/agents.test.ts
@@ -308,6 +308,46 @@ describe("agents API", () => {
expect(result.avatar).toBe("https://example.test/scout.png");
});
+ it("uses a real description when creating a persona", async () => {
+ mockGooseSourcesCreate.mockResolvedValue({ source: agentSource });
+
+ const { createPersona } = await import("../agents");
+ await createPersona({
+ displayName: "Scout",
+ systemPrompt: "Research carefully.",
+ description: "Finds the source you actually need.",
+ });
+
+ expect(mockGooseSourcesCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Finds the source you actually need.",
+ }),
+ );
+ });
+
+ it.each([
+ undefined,
+ "",
+ " ",
+ "Agent",
+ "agent",
+ "Draft",
+ " draft ",
+ ])("falls back to the placeholder description when creating with %j", async (description) => {
+ mockGooseSourcesCreate.mockResolvedValue({ source: agentSource });
+
+ const { createPersona } = await import("../agents");
+ await createPersona({
+ displayName: "Scout",
+ systemPrompt: "Research carefully.",
+ description,
+ });
+
+ expect(mockGooseSourcesCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ description: "Agent" }),
+ );
+ });
+
it("does not store unsupported avatar values on create", async () => {
mockGooseSourcesCreate.mockResolvedValue({ source: agentSource });
@@ -402,6 +442,81 @@ describe("agents API", () => {
});
});
+ it("uses a new real description when updating a persona", async () => {
+ mockGooseSourcesUpdate.mockResolvedValue({ source: agentSource });
+
+ const { updatePersona } = await import("../agents");
+ await updatePersona(loadedPersona, {
+ description: "Finds the source you actually need.",
+ });
+
+ expect(mockGooseSourcesUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Finds the source you actually need.",
+ }),
+ );
+ });
+
+ it("keeps the persona's existing real description when the update omits it", async () => {
+ mockGooseSourcesUpdate.mockResolvedValue({ source: agentSource });
+
+ const { updatePersona } = await import("../agents");
+ await updatePersona(
+ {
+ ...loadedPersona,
+ sourceDescription: "Finds the source you actually need.",
+ },
+ { systemPrompt: "Updated prompt." },
+ );
+
+ expect(mockGooseSourcesUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Finds the source you actually need.",
+ }),
+ );
+ });
+
+ it.each([
+ "",
+ " ",
+ "Agent",
+ "agent",
+ "Draft",
+ " draft ",
+ ])("falls back to the placeholder description when the update explicitly clears it with %j", async (description) => {
+ mockGooseSourcesUpdate.mockResolvedValue({ source: agentSource });
+
+ const { updatePersona } = await import("../agents");
+ // Same behavior as create: explicitly passing a cleared/placeholder
+ // value is a real edit, not "leave it alone" — it does not fall back
+ // to whatever the persona already had.
+ await updatePersona(
+ {
+ ...loadedPersona,
+ sourceDescription: "Finds the source you actually need.",
+ },
+ { description },
+ );
+
+ expect(mockGooseSourcesUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({ description: "Agent" }),
+ );
+ });
+
+ it("falls back to the placeholder description when the persona never had a real one and the update omits it", async () => {
+ mockGooseSourcesUpdate.mockResolvedValue({ source: agentSource });
+
+ const { updatePersona } = await import("../agents");
+ await updatePersona(
+ { ...loadedPersona, sourceDescription: "Draft" },
+ { systemPrompt: "Updated prompt." },
+ );
+
+ expect(mockGooseSourcesUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({ description: "Agent" }),
+ );
+ });
+
it("clears modeled properties while preserving unknown source properties", async () => {
mockGooseSourcesUpdate.mockResolvedValue({
source: {
@@ -439,7 +554,11 @@ describe("agents API", () => {
type: "agent",
path: agentSource.path,
name: "Scout",
- description: "",
+ // The persona's existing sourceDescription is an empty string here,
+ // which was never a real, user-authored description to begin with,
+ // so it falls back to the placeholder rather than being sent through
+ // verbatim.
+ description: "Agent",
content: "Research carefully.",
properties: {
provider: null,
@@ -702,8 +821,12 @@ describe("agents API", () => {
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
expect(mockGooseSourcesExport).not.toHaveBeenCalled();
expect(result).toEqual({
+ // agentSource's description ("Agent") is the API-required placeholder
+ // used when there's no real, user-authored description, not something
+ // a user wrote on purpose, so export substitutes a real fallback
+ // rather than writing "Agent" into the exported file's frontmatter.
contents:
- "---\nname: scout\ndisplay_name: Scout\ndescription: Agent\nmodel: openai:gpt-4.1\navatar: https://example.test/scout.png\n---\n\nResearch carefully.\n",
+ "---\nname: scout\ndisplay_name: Scout\ndescription: Imported Goose agent\nmodel: openai:gpt-4.1\navatar: https://example.test/scout.png\n---\n\nResearch carefully.\n",
filename: "scout.persona.md",
mimeType: "text/markdown",
});
@@ -799,8 +922,9 @@ describe("agents API", () => {
);
expect(result).toEqual({
+ // Same placeholder-description substitution as above.
contents:
- '---\nname: scout\ndisplay_name: Scout\ndescription: Agent\nmodel: openai:gpt-4.1\navatar: https://example.test/scout.png\nsubscribe:\n - "#agents"\ntags:\n - research\n - support\ntools:\n web: true\n---\n\nResearch carefully.\n',
+ '---\nname: scout\ndisplay_name: Scout\ndescription: Imported Goose agent\nmodel: openai:gpt-4.1\navatar: https://example.test/scout.png\nsubscribe:\n - "#agents"\ntags:\n - research\n - support\ntools:\n web: true\n---\n\nResearch carefully.\n',
filename: "scout.persona.md",
mimeType: "text/markdown",
});
diff --git a/src/shared/api/agents.ts b/src/shared/api/agents.ts
index 62efcde1b..590b29534 100644
--- a/src/shared/api/agents.ts
+++ b/src/shared/api/agents.ts
@@ -17,6 +17,17 @@ import {
const AGENT_SOURCE_TYPE = "agent" as const;
const AGENT_DESCRIPTION = "Agent";
+// Keep this string literal in sync with PLACEHOLDER_AGENT_DESCRIPTION in
+// @/features/agents/lib/agentBuilderIdentity.ts. Defined locally, not
+// imported, since that module re-exports the functions below and importing
+// its value in the other direction would create a real circular import
+// (unlike the type-only import that module has on this one).
+const DRAFT_AGENT_DESCRIPTION = "Draft";
+const PLACEHOLDER_AGENT_DESCRIPTIONS = new Set(
+ [AGENT_DESCRIPTION, DRAFT_AGENT_DESCRIPTION].map((value) =>
+ value.toLowerCase(),
+ ),
+);
const PERSONA_MD_EXTENSION = ".persona.md";
const PORTABLE_SPROUT_FRONTMATTER_KEYS = new Set([
"name",
@@ -26,6 +37,26 @@ const PORTABLE_SPROUT_FRONTMATTER_KEYS = new Set([
"avatar",
]);
+// The API layer requires a non-empty description on every source, so a
+// persona with no real, user-authored description still gets a placeholder
+// string under the hood: "Agent" (the old default, before a description
+// field existed in the create/edit form) or "Draft" (a builder draft in
+// progress). Neither was written by the user on purpose, so treat both as
+// "no real description" everywhere a persona's description is read back and
+// shown, exported, or used as a fallback for a newer write.
+export function isPlaceholderAgentDescription(
+ description: string | undefined | null,
+): boolean {
+ const trimmed = description?.trim().toLowerCase();
+ return !trimmed || PLACEHOLDER_AGENT_DESCRIPTIONS.has(trimmed);
+}
+
+export function hasRealAgentDescription(
+ description: string | undefined | null,
+): boolean {
+ return !isPlaceholderAgentDescription(description);
+}
+
export type AgentSourceProperties = {
[key: string]: unknown;
provider?: string | null;
@@ -241,10 +272,9 @@ function sproutFrontmatterFromProperties(
function serializePersonaMarkdown(source: AgentSourceEntry): ExportResult {
const properties = source.properties;
const name = personaExportName(source);
- const description =
- source.description.trim().length > 0
- ? source.description
- : "Imported Goose agent";
+ const description = hasRealAgentDescription(source.description)
+ ? source.description
+ : "Imported Goose agent";
const frontmatter: Record = {
name,
display_name: source.name,
@@ -798,7 +828,9 @@ export async function createPersona(
const response = await client.goose.GooseUnstableSourcesCreate({
type: AGENT_SOURCE_TYPE,
name: request.displayName,
- description: AGENT_DESCRIPTION,
+ description: hasRealAgentDescription(request.description)
+ ? (request.description as string).trim()
+ : AGENT_DESCRIPTION,
content: request.systemPrompt,
target: { scope: "global" },
properties: personaProperties(request),
@@ -824,7 +856,19 @@ export async function updatePersona(
request: UpdatePersonaRequest,
): Promise {
const client = await getClient();
- const description = persona.sourceDescription ?? AGENT_DESCRIPTION;
+ // A description explicitly passed in this request wins, even if it's an
+ // empty string (clearing the field is a real, intentional edit — same
+ // as create, an empty/placeholder value falls back rather than being
+ // written through literally). Otherwise, keep whatever the persona
+ // already had, falling back only if that was never real to begin with.
+ const description =
+ request.description !== undefined
+ ? hasRealAgentDescription(request.description)
+ ? request.description.trim()
+ : AGENT_DESCRIPTION
+ : hasRealAgentDescription(persona.sourceDescription)
+ ? (persona.sourceDescription as string).trim()
+ : AGENT_DESCRIPTION;
const response = await client.goose.GooseUnstableSourcesUpdate({
type: AGENT_SOURCE_TYPE,
diff --git a/src/shared/i18n/locales/en/agents.json b/src/shared/i18n/locales/en/agents.json
index d0a3d329f..41a09635e 100644
--- a/src/shared/i18n/locales/en/agents.json
+++ b/src/shared/i18n/locales/en/agents.json
@@ -22,6 +22,8 @@
"invalidFrontmatterTitle": "Invalid frontmatter",
"invalidFrontmatterBody": "Fix in chat, or open the source file in an editor.",
"openFailed": "Couldn't open the agent editor.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "A short line about what this agent does",
"instructionsLabel": "Agent instructions",
"instructionsPlaceholder": "Describe the agent's goal and instructions",
"selectedAvatar": "Selected avatar",
@@ -231,6 +233,7 @@
"title": "Agents",
"backToAgents": "Back to agents",
"created": "Created",
+ "description": "Description",
"more": "More",
"source": "Source",
"updated": "Updated"
diff --git a/src/shared/i18n/locales/es/agents.json b/src/shared/i18n/locales/es/agents.json
index 1f5155aec..a955700a9 100644
--- a/src/shared/i18n/locales/es/agents.json
+++ b/src/shared/i18n/locales/es/agents.json
@@ -22,6 +22,8 @@
"invalidFrontmatterTitle": "Frontmatter inválido",
"invalidFrontmatterBody": "Corrígelo desde el chat o abre el archivo en un editor.",
"openFailed": "No se pudo abrir el editor de agente.",
+ "descriptionLabel": "Descripción",
+ "descriptionPlaceholder": "Una línea breve sobre lo que hace este agente",
"instructionsLabel": "Instrucciones del agente",
"instructionsPlaceholder": "Describe el objetivo e instrucciones del agente",
"selectedAvatar": "Avatar seleccionado",
@@ -231,6 +233,7 @@
"title": "Agentes",
"backToAgents": "Volver a agentes",
"created": "Creado",
+ "description": "Descripción",
"more": "Más",
"source": "Origen",
"updated": "Actualizado"
diff --git a/src/shared/types/agents.ts b/src/shared/types/agents.ts
index 655823e55..70af47157 100644
--- a/src/shared/types/agents.ts
+++ b/src/shared/types/agents.ts
@@ -26,6 +26,7 @@ export interface Persona {
export interface CreatePersonaRequest {
displayName: string;
+ description?: string;
avatar?: Avatar | null;
systemPrompt: string;
provider?: ProviderType;
@@ -35,6 +36,7 @@ export interface CreatePersonaRequest {
export interface UpdatePersonaRequest {
displayName?: string;
+ description?: string;
avatar?: Avatar | null;
systemPrompt?: string;
provider?: ProviderType | null;