feat(personas): skip agent spawn when saving an agent as a persona template

The "Save as persona template" route on an existing agent flowed through
the shared submitProfilePersonaDialog no-id branch, which always minted the
persona AND spawned a brand-new running agent. tho's call: save-as must mint
the template only, no second instance.

Thread a templateOnly intent flag from saveAsPersonaTemplateDialogState
through handleSubmitPersona into submit; the no-id branch skips
createManagedAgentForPersona and shows a "Saved … as a persona template"
toast only when set. Every other create-persona flow leaves it unset and
keeps spawning as before — blast radius stays on the save-as path.

Lock the behavior with tests: save-as creates the persona but spawns no
agent; the plain create path still spawns. Bump the UserProfilePanel.tsx
file-size override 1014->1017 for the threaded plumbing.

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 14:28:56 -07:00
co-authored by Taylor Ho
parent 73eab83ea5
commit 15bd19b994
6 changed files with 147 additions and 20 deletions
+3 -2
View File
@@ -103,9 +103,10 @@ const overrides = new Map([
// singular-create persona-template: the "Save as persona template" re-home
// adds saveAsPersonaTemplateDialogState import + handleSaveAsTemplate handler
// + the Slot prop (~15 lines) onto main's already-decomposed-but-edge-riding
// panel (998 lines on main). A small overage from load-bearing feature
// panel (998 lines on main); the no-spawn follow-up threads templateOnly
// through handleSubmitPersona (+3). A small overage from load-bearing feature
// plumbing, not generic debt growth. Queued to split with the rest.
["src/features/profile/ui/UserProfilePanel.tsx", 1014],
["src/features/profile/ui/UserProfilePanel.tsx", 1017],
]);
await runFileSizeCheck({
@@ -302,6 +302,16 @@ test("saveAsPersonaTemplateDialogState uses persona-template vocabulary", () =>
assert.equal(state.description, "Reuse this setup to create more agents.");
});
test("saveAsPersonaTemplateDialogState marks the dialog template-only so submit skips the agent spawn", () => {
const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
assert.equal(state.templateOnly, true);
});
test("createPersonaDialogState does not mark template-only (it still spawns)", () => {
assert.equal(createPersonaDialogState().templateOnly, undefined);
});
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.
@@ -12,6 +12,15 @@ export type PersonaDialogState = {
description: string;
initialValues: CreatePersonaInput | UpdatePersonaInput;
submitLabel: string;
/**
* Template-only intent. When `true`, submit mints the persona template and
* stops there — it must NOT spawn a managed agent. Only the "Save as persona
* template" route on an existing agent sets this; every other create-persona
* flow leaves it unset so it keeps spawning its agent as before. The
* structural `CreatePersonaInput` is identical across routes, so this flag is
* the sole signal distinguishing "save a template" from "create + spawn".
*/
templateOnly?: boolean;
title: string;
};
@@ -132,6 +141,9 @@ export function saveAsPersonaTemplateDialogState(
title: "Save as persona template",
description: "Reuse this setup to create more agents.",
submitLabel: "Save as persona template",
// Template-only: minting the template is the whole job here. Submit must
// NOT spawn a duplicate running agent off the agent we promoted from.
templateOnly: true,
initialValues: {
displayName: agent.name,
avatarUrl: "",
@@ -558,6 +558,8 @@ export function UserProfilePanel({
},
previousPersona: resolvedPersona,
runtimes: acpRuntimesQuery.data ?? [],
// Template-only intent → submit mints the template, spawns no agent.
templateOnly: personaDialogState?.templateOnly,
updateManagedAgent: updateManagedAgentMutation.mutateAsync,
updatePersona: updatePersonaMutation.mutateAsync,
});
@@ -566,6 +568,7 @@ export function UserProfilePanel({
createPersonaMutation.mutateAsync,
createManagedAgentForPersona,
managedAgent,
personaDialogState?.templateOnly,
personasQuery.refetch,
resolvedPersona,
acpRuntimesQuery.data,
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateLinkedAgentRuntimeEdit } from "./UserProfilePanelPersonaSubmit.ts";
import {
submitProfilePersonaDialog,
validateLinkedAgentRuntimeEdit,
} from "./UserProfilePanelPersonaSubmit.ts";
function agent(overrides = {}) {
return {
@@ -73,6 +76,20 @@ function updateInput(overrides = {}) {
};
}
function createInput(overrides = {}) {
return {
displayName: "Fizz",
avatarUrl: "",
systemPrompt: "Prompt",
runtime: "goose",
model: undefined,
provider: undefined,
namePool: [],
envVars: {},
...overrides,
};
}
function runtime(overrides = {}) {
return {
id: "claude",
@@ -136,3 +153,72 @@ test("validateLinkedAgentRuntimeEdit allows unchanged or unlinked runtime prefer
null,
);
});
// Helpers to build a submit-options bundle with spy-able mutations. Mutations
// default to recording their calls so a test can assert spawn behavior.
function submitOptions(overrides = {}) {
const calls = {
createPersona: [],
createManagedAgentForPersona: [],
onDone: 0,
};
const createdPersona = persona({ id: "new-persona", displayName: "Fizz" });
const options = {
createManagedAgentForPersona: async (p) => {
calls.createManagedAgentForPersona.push(p);
return {
agent: agent({ name: "Fizz", personaId: "new-persona" }),
spawnError: null,
profileSyncError: null,
};
},
createPersona: async (input) => {
calls.createPersona.push(input);
return createdPersona;
},
input: createInput(),
managedAgent: undefined,
onDone: () => {
calls.onDone += 1;
},
previousPersona: undefined,
runtimes: [],
templateOnly: undefined,
updateManagedAgent: async () => {
throw new Error("updateManagedAgent should not be called in create path");
},
updatePersona: async () => {
throw new Error("updatePersona should not be called in create path");
},
...overrides,
};
return { calls, options };
}
test("submitProfilePersonaDialog template-only creates the persona but spawns no agent", async () => {
const { calls, options } = submitOptions({ templateOnly: true });
await submitProfilePersonaDialog(options);
assert.equal(calls.createPersona.length, 1, "persona template is created");
assert.equal(
calls.createManagedAgentForPersona.length,
0,
"no managed agent is spawned for a template-only save-as",
);
assert.equal(calls.onDone, 1, "dialog closes on success");
});
test("submitProfilePersonaDialog create path still spawns an agent when not template-only", async () => {
const { calls, options } = submitOptions({ templateOnly: undefined });
await submitProfilePersonaDialog(options);
assert.equal(calls.createPersona.length, 1, "persona is created");
assert.equal(
calls.createManagedAgentForPersona.length,
1,
"legit create-and-spawn flow is unaffected",
);
assert.equal(calls.onDone, 1, "dialog closes on success");
});
@@ -21,6 +21,14 @@ type SubmitProfilePersonaDialogOptions = {
onDone: () => void;
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
/**
* Template-only intent (set by the "Save as persona template" route on an
* existing agent). When `true`, the no-`id` branch mints the persona template
* and stops — it does NOT call `createManagedAgentForPersona`, so no
* duplicate agent spawns. Every other create-persona flow leaves this unset
* and keeps spawning its agent as before.
*/
templateOnly?: boolean;
updateManagedAgent: (
input: UpdateManagedAgentInput,
) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>;
@@ -71,6 +79,7 @@ export async function submitProfilePersonaDialog({
onDone,
previousPersona,
runtimes,
templateOnly,
updateManagedAgent,
updatePersona,
}: SubmitProfilePersonaDialogOptions) {
@@ -103,26 +112,32 @@ export async function submitProfilePersonaDialog({
toast.success(`Updated ${input.displayName}.`);
} else {
const persona = await createPersona(input);
try {
const created = await createManagedAgentForPersona(persona);
if (created.spawnError) {
if (templateOnly) {
// "Save as persona template" from an existing agent: minting the
// template is the whole job — do NOT spawn a duplicate running agent.
toast.success(`Saved ${persona.displayName} as a persona template.`);
} else {
try {
const created = await createManagedAgentForPersona(persona);
if (created.spawnError) {
toast.error(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);
} else {
toast.success(`Created and started ${created.agent.name}.`);
}
if (created.profileSyncError) {
toast.warning(
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
);
}
} catch (error) {
toast.error(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);
} else {
toast.success(`Created and started ${created.agent.name}.`);
}
if (created.profileSyncError) {
toast.warning(
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
error instanceof Error
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
: `${persona.displayName} was created, but the agent instance could not be created.`,
);
}
} catch (error) {
toast.error(
error instanceof Error
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
: `${persona.displayName} was created, but the agent instance could not be created.`,
);
}
}