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