mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
36d3d2ed1e
commit
c9debf3620
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user