diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2e6f62009..ffd7710db 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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({ diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index 1f9817013..1663f7f97 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -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. diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index 24571d519..47c5b3a43 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -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: "", diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 8acd0d52e..b50a619c4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -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, diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs index 406cc3f09..64a6cb30c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts index eff9a1af1..0e02531b1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts @@ -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.`, - ); } }