feat(agents): re-home save-as-persona-template onto an existing agent

Adds a 'Save as persona template' action to an existing agent's menus,
re-homing the #1301 logic onto main's surfaces:

- saveAsPersonaTemplateDialogState() prefills the persona editor from a
  ManagedAgent, reverse-mapping the agent's harness command to a runtime
  id via commandsMatch (graceful fallback to no runtime). provider is
  omitted intentionally: ManagedAgent has no top-level provider field on
  main, so there is nothing lossless to carry.
- useSaveAsPersonaTemplate() is a self-contained hook (dialog + mutation)
  so a later visual reskin can move the trigger without untangling the
  behavior. Derives dialog state from agent + live runtimes via a memo to
  avoid the lazy-runtime-query first-open race.
- Wired into ManagedAgentRow's actions menu (gated personaId === null) and
  the sidebar agent profile (gated canEditAgent + persona-less).
- personaLibraryCopy.ts documents the UI<->backend vocabulary boundary:
  'persona template' is the product term for a backend persona (kind:30175).

Tests cover the dialog-state mapping: vocabulary, command reverse-map hit,
miss/empty-catalog fallback, null-command catalog entries, field carry-over,
provider omission, and null systemPrompt/model tolerance.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-26 13:55:01 -07:00
co-authored by Taylor Ho
parent 36d3d2ed1e
commit c9debf3620
7 changed files with 336 additions and 0 deletions
@@ -1,6 +1,7 @@
import * as React from "react";
import {
BookmarkPlus,
ChevronDown,
ChevronRight,
Clipboard,
@@ -39,6 +40,8 @@ import { EditAgentDialog } from "./EditAgentDialog";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { ModelPicker } from "./ModelPicker";
import { PersonaDialog } from "./PersonaDialog";
import { useSaveAsPersonaTemplate } from "./useSaveAsPersonaTemplate";
import { truncatePubkey } from "./agentUi";
export function ManagedAgentRow({
@@ -423,6 +426,7 @@ function AgentActionsMenu({
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
}) {
const [editOpen, setEditOpen] = React.useState(false);
const saveAsTemplate = useSaveAsPersonaTemplate();
return (
<>
@@ -483,6 +487,13 @@ function AgentActionsMenu({
</DropdownMenuItem>
) : null}
{agent.personaId === null ? (
<DropdownMenuItem onClick={() => saveAsTemplate.open(agent)}>
<BookmarkPlus className="h-4 w-4" />
Save as persona template
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onAddToChannel(agent)}
@@ -542,6 +553,10 @@ function AgentActionsMenu({
open={editOpen}
/>
) : null}
{saveAsTemplate.dialogState ? (
<PersonaDialog {...saveAsTemplate.dialogProps} />
) : null}
</>
);
}
@@ -6,6 +6,7 @@ import {
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
saveAsPersonaTemplateDialogState,
} from "./personaDialogState.ts";
test("createPersonaDialogState returns a fresh empty draft", () => {
@@ -228,3 +229,107 @@ test("importPersonaDialogState preserves provider=anthropic", () => {
assert.equal(state.initialValues.provider, "anthropic");
});
// ── saveAsPersonaTemplateDialogState (promote an agent → persona template) ────
/** Minimal ManagedAgent fixture; only the fields the builder reads matter. */
function makeAgent(overrides = {}) {
return {
pubkey: "npub-agent",
name: "Scout",
personaId: null,
relayUrl: "wss://relay",
acpCommand: "",
agentCommand: "/usr/local/bin/goose-acp",
agentArgs: [],
mcpCommand: "",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 24,
systemPrompt: "Scout the codebase.",
model: "claude-sonnet",
mcpToolsets: null,
envVars: { ANTHROPIC_API_KEY: "sk-test" },
status: "stopped",
pid: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
...overrides,
};
}
test("saveAsPersonaTemplateDialogState uses persona-template vocabulary", () => {
const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
assert.equal(state.title, "Save as persona template");
assert.equal(state.submitLabel, "Save as persona template");
assert.equal(state.description, "Reuse this setup to create more agents.");
});
test("saveAsPersonaTemplateDialogState reverse-maps agentCommand to a runtime id", () => {
// commandsMatch compares basenames, so the absolute agentCommand path
// resolves to the catalog runtime whose command shares that basename.
const state = saveAsPersonaTemplateDialogState(makeAgent(), [
{ id: "claude", label: "Claude", command: "claude-code-acp" },
{ id: "goose", label: "Goose", command: "goose-acp" },
]);
assert.equal(state.initialValues.runtime, "goose");
});
test("saveAsPersonaTemplateDialogState leaves runtime undefined when nothing matches", () => {
// Graceful fallback: an unknown command (or an empty/unloaded catalog)
// yields no runtime, and the dialog falls back to its default behavior.
const noMatch = saveAsPersonaTemplateDialogState(makeAgent(), [
{ id: "claude", label: "Claude", command: "claude-code-acp" },
]);
const empty = saveAsPersonaTemplateDialogState(makeAgent(), []);
assert.equal(noMatch.initialValues.runtime, undefined);
assert.equal(empty.initialValues.runtime, undefined);
});
test("saveAsPersonaTemplateDialogState ignores catalog entries with a null command", () => {
const state = saveAsPersonaTemplateDialogState(makeAgent(), [
{ id: "not-installed", label: "Not Installed", command: null },
{ id: "goose", label: "Goose", command: "goose-acp" },
]);
assert.equal(state.initialValues.runtime, "goose");
});
test("saveAsPersonaTemplateDialogState carries name, prompt, model, and envVars", () => {
const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
assert.equal(state.initialValues.displayName, "Scout");
assert.equal(state.initialValues.systemPrompt, "Scout the codebase.");
assert.equal(state.initialValues.model, "claude-sonnet");
assert.deepEqual(state.initialValues.envVars, {
ANTHROPIC_API_KEY: "sk-test",
});
// namePool is persona-only and starts empty for the user to fill.
assert.deepEqual(state.initialValues.namePool, []);
});
test("saveAsPersonaTemplateDialogState omits provider (no top-level field on ManagedAgent)", () => {
// A ManagedAgent has no top-level provider, so there is nothing lossless to
// carry — the builder must not invent one.
const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
assert.equal("provider" in state.initialValues, false);
});
test("saveAsPersonaTemplateDialogState tolerates null systemPrompt and model", () => {
const state = saveAsPersonaTemplateDialogState(
makeAgent({ systemPrompt: null, model: null }),
[],
);
assert.equal(state.initialValues.systemPrompt, "");
assert.equal(state.initialValues.model, undefined);
});
@@ -1,7 +1,10 @@
import { commandsMatch } from "@/features/agents/agentReuse";
import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas";
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
CreatePersonaInput,
ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
@@ -56,6 +59,60 @@ export function duplicatePersonaDialogState(
};
}
/**
* Reverse-map a managed agent's resolved harness command back to an ACP
* runtime ID, so the persona dialog can pre-select the matching runtime.
* Returns `undefined` when no runtime matches (or none are loaded yet) — the
* dialog then falls back to its default-runtime behavior.
*/
function runtimeIdForAgentCommand(
agentCommand: string,
runtimes: readonly AcpRuntimeCatalogEntry[],
): string | undefined {
const match = runtimes.find(
(runtime) =>
runtime.command !== null && commandsMatch(runtime.command, agentCommand),
);
return match?.id;
}
/**
* Dialog state for the opt-in "Save as persona template" action on an existing
* agent. Prefills the persona editor from the agent so the user reviews and
* confirms before a persona template is created — nothing is minted silently.
*
* Near-lossless promote: name, system prompt, model, and env vars copy
* straight across; the harness command reverse-maps to a runtime ID.
* `namePool` is persona-only and starts empty — the user can fill it in the
* same dialog (it's how a template bulk-adds bots later).
*
* Note: "persona template" is the UI name for what the backend calls a
* `persona` (kind:30175). This builder produces a backend `CreatePersonaInput`.
* `provider` is intentionally not carried: a `ManagedAgent` does not expose a
* top-level provider field (it lives in backend/provider config), so there is
* nothing lossless to copy here.
*/
export function saveAsPersonaTemplateDialogState(
agent: ManagedAgent,
runtimes: readonly AcpRuntimeCatalogEntry[],
): PersonaDialogState {
return {
title: "Save as persona template",
description: "Reuse this setup to create more agents.",
submitLabel: "Save as persona template",
initialValues: {
displayName: agent.name,
avatarUrl: "",
systemPrompt: agent.systemPrompt ?? "",
runtime: runtimeIdForAgentCommand(agent.agentCommand, runtimes),
model: agent.model ?? undefined,
// namePool is persona-only; start empty so the user fills it here.
namePool: [],
envVars: agent.envVars ?? {},
},
};
}
export function editPersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
@@ -1,3 +1,23 @@
/**
* UI copy for the persona library surfaces.
*
* ── Vocabulary boundary (UI ↔ backend) ──────────────────────────────────────
* The product-facing term is **"persona template"**: a reusable setup a user
* can save once and reuse to create more agents. The backend has no separate
* "template" concept — a persona template IS a **`persona`** (Nostr
* **kind:30175**). There is intentionally zero drift between the two:
*
* UI string backend entity / mutation
* ─────────────────────── ────────────────────────────────────────────────
* "persona template" ⇄ `persona` (kind:30175)
* "Save as persona ⇄ `createPersonaMutation` / `CreatePersonaInput`
* template"
*
* So every "...persona template" label here, in `CreateAgentDialog`'s opt-in
* toggle, and in the `useSaveAsPersonaTemplate` / `saveAsPersonaTemplateDialogState`
* save-as flow maps to the same backend `persona`. Keep new persona-template
* copy in this file and keep the mapping above current if the vocabulary moves.
*/
export const personaLibraryCopy = {
title: "My agents",
description:
@@ -0,0 +1,99 @@
import * as React from "react";
import { toast } from "sonner";
import {
useAcpRuntimesQuery,
useCreatePersonaMutation,
} from "@/features/agents/hooks";
import type {
CreatePersonaInput,
ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
import {
saveAsPersonaTemplateDialogState,
type PersonaDialogState,
} from "./personaDialogState";
/**
* Self-contained "Save as persona template" flow for surfaces that don't
* already host `usePersonaActions` (e.g. an agent row's actions menu or the
* sidebar agent profile). Opens the shared `PersonaDialog` prefilled from an
* agent and creates a backend persona on submit — no new backend or IPC.
*
* Keeping the dialog + mutation behind this hook (rather than welding them to
* a host component's layout) means a later visual reskin of the create surface
* can move the trigger without untangling the save-as behavior.
*
* "Persona template" is the UI name for what the backend calls a `persona`
* (kind:30175); this hook produces a `CreatePersonaInput`.
*/
export function useSaveAsPersonaTemplate() {
// Hold the agent being promoted (rather than a one-shot snapshot) so the
// runtime reverse-map can re-resolve when the runtime list arrives — the
// query is lazy (`enabled` flips on open), so its data is usually empty on
// the very first open. Deriving the dialog state from `agent` + live
// runtimes via a memo avoids that first-open race.
const [agent, setAgent] = React.useState<ManagedAgent | null>(null);
// Only fetch runtimes once the user actually opens the dialog.
const acpRuntimesQuery = useAcpRuntimesQuery({ enabled: agent !== null });
const createPersonaMutation = useCreatePersonaMutation();
const dialogState = React.useMemo<PersonaDialogState | null>(
() =>
agent
? saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? [])
: null,
[agent, acpRuntimesQuery.data],
);
const open = React.useCallback((nextAgent: ManagedAgent) => {
setAgent(nextAgent);
}, []);
const close = React.useCallback(() => {
setAgent(null);
}, []);
const handleSubmit = React.useCallback(
async (input: CreatePersonaInput | UpdatePersonaInput) => {
// The save-as flow only ever produces a create input.
if ("id" in input) return;
try {
await createPersonaMutation.mutateAsync(input);
toast.success(`Saved ${input.displayName} as a persona template.`);
setAgent(null);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to save persona template.",
);
}
},
[createPersonaMutation],
);
return {
open,
dialogState,
dialogProps: {
open: dialogState !== null,
title: dialogState?.title ?? "",
description: dialogState?.description ?? "",
submitLabel: dialogState?.submitLabel ?? "",
initialValues: dialogState?.initialValues ?? null,
error:
createPersonaMutation.error instanceof Error
? createPersonaMutation.error
: null,
isPending: createPersonaMutation.isPending,
runtimes: acpRuntimesQuery.data ?? [],
runtimesLoading: acpRuntimesQuery.isLoading,
onOpenChange: (next: boolean) => {
if (!next) close();
},
onSubmit: handleSubmit,
},
};
}
@@ -14,6 +14,8 @@ import {
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog";
import { PersonaDialog } from "@/features/agents/ui/PersonaDialog";
import { useSaveAsPersonaTemplate } from "@/features/agents/ui/useSaveAsPersonaTemplate";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import {
@@ -242,6 +244,17 @@ export function UserProfilePanel({
setEditAgentOpen(true);
}, []);
const saveAsTemplate = useSaveAsPersonaTemplate();
const canSaveAsTemplate =
canEditAgent &&
managedAgent !== undefined &&
managedAgent.personaId === null;
const handleSaveAsTemplate = React.useCallback(() => {
if (managedAgent) {
saveAsTemplate.open(managedAgent);
}
}, [managedAgent, saveAsTemplate]);
const handleOpenActivity = React.useCallback(() => {
onClose();
onOpenAgentSession?.(pubkey);
@@ -353,6 +366,8 @@ export function UserProfilePanel({
handleEditAgent={handleEditAgent}
handleMessage={handleMessage}
handleOpenActivity={handleOpenActivity}
handleSaveAsTemplate={handleSaveAsTemplate}
canSaveAsTemplate={canSaveAsTemplate}
isBot={isBot}
isFollowing={isFollowing}
isOwner={isOwner}
@@ -405,6 +420,10 @@ export function UserProfilePanel({
/>
) : null;
const saveAsTemplateDialog = saveAsTemplate.dialogState ? (
<PersonaDialog {...saveAsTemplate.dialogProps} />
) : null;
if (isSplitLayout) {
return (
<>
@@ -416,6 +435,7 @@ export function UserProfilePanel({
{profileBody}
</div>
{editAgentDialog}
{saveAsTemplateDialog}
</>
);
}
@@ -481,6 +501,7 @@ export function UserProfilePanel({
{profileBody}
</aside>
{editAgentDialog}
{saveAsTemplateDialog}
</>
);
}
@@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowUpRight,
BookmarkPlus,
Brain,
ChevronDown,
ChevronRight,
@@ -73,6 +74,8 @@ export type ProfileSummaryViewProps = {
handleEditAgent: () => void;
handleMessage: () => void;
handleOpenActivity: () => void;
handleSaveAsTemplate: () => void;
canSaveAsTemplate: boolean;
isBot: boolean;
isFollowing: boolean;
isOwner: boolean | undefined;
@@ -108,6 +111,8 @@ export function ProfileSummaryView({
handleEditAgent,
handleMessage,
handleOpenActivity,
handleSaveAsTemplate,
canSaveAsTemplate,
isBot,
isFollowing,
isOwner,
@@ -174,8 +179,10 @@ export function ProfileSummaryView({
{!isSelf ? (
<ProfilePrimaryActions
canEditAgent={canEditAgent}
canSaveAsTemplate={canSaveAsTemplate}
followMutation={followMutation}
onEditAgent={handleEditAgent}
onSaveAsTemplate={handleSaveAsTemplate}
isFollowing={isFollowing}
onMessage={onOpenDm ? handleMessage : undefined}
pubkey={pubkey}
@@ -427,17 +434,21 @@ function ProfileHeroDescription({ about }: { about: string }) {
function ProfilePrimaryActions({
canEditAgent,
canSaveAsTemplate,
followMutation,
isFollowing,
onEditAgent,
onSaveAsTemplate,
onMessage,
pubkey,
unfollowMutation,
}: {
canEditAgent: boolean;
canSaveAsTemplate: boolean;
followMutation: ReturnType<typeof useFollowMutation>;
isFollowing: boolean;
onEditAgent: () => void;
onSaveAsTemplate: () => void;
onMessage?: () => void;
pubkey: string;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
@@ -481,6 +492,14 @@ function ProfilePrimaryActions({
testId="user-profile-edit-agent"
/>
) : null}
{canSaveAsTemplate ? (
<ProfileQuickAction
icon={BookmarkPlus}
label="Save as template"
onClick={onSaveAsTemplate}
testId="user-profile-save-as-template"
/>
) : null}
</div>
);
}