Skip to content

Commit 202201f

Browse files
wesbillmanPinky
andauthored
fix(desktop): allow editing built-in agents (#1928)
Signed-off-by: Wes <[email protected]> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
1 parent 3295769 commit 202201f

10 files changed

Lines changed: 69 additions & 99 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests
104104
in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with
105105
pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting
106106
in one shot. Run `just ci` for the full local gate. Run `just hooks` to
107-
re-install hooks after env changes.
107+
re-install hooks after env changes. Before agents run Git or hooks, activate the
108+
repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook
109+
commands to compensate for an unconfigured shell `PATH`.
108110

109111
Additional rules:
110112
- No `unsafe` code

‎desktop/src-tauri/src/commands/personas/mod.rs‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,6 @@ pub async fn update_persona(
175175
.find(|record| record.id == input.id)
176176
.ok_or_else(|| format!("agent {} not found", input.id))?;
177177

178-
if persona.is_builtin {
179-
return Err("Built-in agents cannot be edited.".to_string());
180-
}
181-
182178
// Track what changed so we can propagate to linked agent records.
183179
let avatar_changed = persona.avatar_url != avatar_url;
184180
let name_changed = persona.display_name != display_name;

‎desktop/src-tauri/src/managed_agents/personas.rs‎

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -159,27 +159,8 @@ fn merge_personas(mut stored: Vec<AgentDefinition>, now: &str) -> (Vec<AgentDefi
159159

160160
for built_in in built_in_persona_records(now) {
161161
if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) {
162-
let created_at = existing.created_at.clone();
163-
let updated_at = existing.updated_at.clone();
164-
let is_active = existing.is_active;
165-
// Built-in fields are canonical — user overrides on runtime/model are
166-
// intentionally not preserved across restarts. Users who want a custom
167-
// model or runtime should clone the built-in as a custom persona.
168-
if existing.display_name != built_in.display_name
169-
|| existing.avatar_url != built_in.avatar_url
170-
|| existing.system_prompt != built_in.system_prompt
171-
|| existing.name_pool != built_in.name_pool
172-
|| existing.env_vars != built_in.env_vars
173-
|| existing.runtime != built_in.runtime
174-
|| existing.model != built_in.model
175-
|| !existing.is_builtin
176-
{
177-
*existing = AgentDefinition {
178-
created_at,
179-
updated_at,
180-
is_active,
181-
..built_in
182-
};
162+
if !existing.is_builtin {
163+
existing.is_builtin = true;
183164
changed = true;
184165
}
185166
} else {

‎desktop/src-tauri/src/managed_agents/personas/tests.rs‎

Lines changed: 18 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -65,85 +65,43 @@ fn merge_personas_preserves_custom_records() {
6565
}
6666

6767
#[test]
68-
fn merge_personas_restores_builtin_defaults() {
68+
fn merge_personas_preserves_builtin_edits() {
6969
let mut edited_builtin = custom_persona("builtin:fizz", "My Fizz");
7070
edited_builtin.is_builtin = true;
7171
edited_builtin.is_active = true;
72-
let original_created_at = edited_builtin.created_at.clone();
73-
let original_updated_at = edited_builtin.updated_at.clone();
72+
edited_builtin.system_prompt = "User-edited instructions".to_string();
73+
edited_builtin.name_pool = vec!["User-edited name".to_string()];
74+
edited_builtin.env_vars =
75+
std::collections::BTreeMap::from([("USER_SETTING".to_string(), "value".to_string())]);
7476

75-
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
77+
let (records, changed) = merge_personas(vec![edited_builtin.clone()], "2026-03-19T00:00:00Z");
7678

77-
assert!(changed);
79+
assert!(changed); // The remaining seeded built-ins are added.
7880
let fizz = records
7981
.iter()
8082
.find(|record| record.id == "builtin:fizz")
8183
.expect("fizz built-in should exist");
82-
let canonical = BUILT_IN_PERSONAS
83-
.iter()
84-
.find(|persona| persona.id == "builtin:fizz")
85-
.expect("fizz built-in definition should exist");
86-
assert_eq!(fizz.display_name, canonical.display_name);
87-
assert_eq!(fizz.avatar_url.as_deref(), canonical.avatar_url,);
88-
assert_eq!(fizz.created_at, original_created_at);
89-
assert_eq!(fizz.updated_at, original_updated_at);
90-
assert!(fizz.is_active);
84+
assert_eq!(fizz.display_name, edited_builtin.display_name);
85+
assert_eq!(fizz.system_prompt, edited_builtin.system_prompt);
86+
assert_eq!(fizz.name_pool, edited_builtin.name_pool);
87+
assert_eq!(fizz.env_vars, edited_builtin.env_vars);
88+
assert_eq!(fizz.is_active, edited_builtin.is_active);
9189
}
9290

9391
#[test]
94-
fn merge_personas_restores_builtin_env_vars() {
95-
// A hand-edited built-in record with stray env vars should be reset to
96-
// the canonical (empty) env on merge. Built-ins are intended immutable —
97-
// if a user wants per-persona credentials, they create or duplicate to a
98-
// custom persona.
99-
let mut tampered = custom_persona("builtin:fizz", "Fizz");
100-
tampered.is_builtin = true;
101-
tampered.avatar_url = None;
102-
tampered.is_active = true;
103-
tampered.env_vars =
104-
std::collections::BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "leaked".to_string())]);
105-
106-
let (records, changed) = merge_personas(vec![tampered], "2026-03-19T00:00:00Z");
107-
108-
assert!(changed);
109-
let fizz = records
110-
.iter()
111-
.find(|record| record.id == "builtin:fizz")
112-
.expect("fizz built-in should exist");
113-
// Built-in persona definitions have no `env_vars` field — they are
114-
// always empty. The merge reset should clear the tampered key entirely.
115-
assert!(
116-
fizz.env_vars.is_empty(),
117-
"expected empty, got {:?}",
118-
fizz.env_vars
119-
);
120-
}
121-
122-
#[test]
123-
fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() {
124-
let mut fizz = custom_persona("builtin:fizz", "Fizz");
125-
fizz.is_builtin = true;
126-
fizz.avatar_url = None;
127-
fizz.is_active = true;
128-
fizz.name_pool = vec!["Definitely Not Fizz".to_string()];
92+
fn merge_personas_restores_builtin_marker_without_resetting_edits() {
93+
let mut edited_builtin = custom_persona("builtin:fizz", "My Fizz");
94+
edited_builtin.is_builtin = false;
12995

130-
let (records, changed) = merge_personas(vec![fizz], "2026-03-19T00:00:00Z");
96+
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
13197

13298
assert!(changed);
13399
let fizz = records
134100
.iter()
135101
.find(|record| record.id == "builtin:fizz")
136102
.expect("fizz built-in should exist");
137-
let expected_name_pool = BUILT_IN_PERSONAS
138-
.iter()
139-
.find(|persona| persona.id == "builtin:fizz")
140-
.expect("fizz built-in definition should exist")
141-
.name_pool
142-
.iter()
143-
.map(|name| (*name).to_string())
144-
.collect::<Vec<_>>();
145-
assert_eq!(fizz.name_pool, expected_name_pool);
146-
assert!(fizz.is_active);
103+
assert!(fizz.is_builtin);
104+
assert_eq!(fizz.display_name, "My Fizz");
147105
}
148106

149107
#[test]

‎desktop/src/features/agents/agentManagement.test.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test("allows agents to update only personal, editable profiles", () => {
9797
);
9898
assert.equal(
9999
requestTargetsEditablePersona({ isBuiltIn: true, sourceTeam: null }),
100-
false,
100+
true,
101101
);
102102
assert.equal(
103103
requestTargetsEditablePersona({ isBuiltIn: false, sourceTeam: "team" }),

‎desktop/src/features/agents/agentManagement.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export function parseAgentManagementRequest(
137137
export function requestTargetsEditablePersona(
138138
persona: AgentPersona | undefined,
139139
): persona is AgentPersona {
140-
return Boolean(persona && !persona.isBuiltIn && !persona.sourceTeam);
140+
return Boolean(persona && !persona.sourceTeam);
141141
}
142142

143143
export function createInputFromRequest(

‎desktop/src/features/agents/ui/PersonaActionsMenu.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function PersonaActionsMenu({
4141
onDelete: (persona: AgentPersona) => void;
4242
}) {
4343
const disabled = isActionPending || isPending;
44-
const canEdit = !persona.isBuiltIn && !persona.sourceTeam;
44+
const canEdit = !persona.sourceTeam;
4545

4646
return (
4747
<DropdownMenu modal={false}>

‎desktop/src/features/profile/ui/UserProfilePanel.tsx‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,7 @@ export function UserProfilePanel({
322322
// covering both locally managed agents and declared-owned relay agents.
323323
const canEditAgent =
324324
isOwner === true &&
325-
(managedAgent !== undefined ||
326-
(resolvedPersona !== undefined && !resolvedPersona.isBuiltIn));
325+
(managedAgent !== undefined || resolvedPersona !== undefined);
327326
const memoryQuery = useAgentMemoryQuery(effectivePubkey, {
328327
enabled: viewerIsOwner && Boolean(effectivePubkey),
329328
});
@@ -395,7 +394,7 @@ export function UserProfilePanel({
395394
});
396395

397396
const handleEditAgent = React.useCallback(() => {
398-
if (resolvedPersona && !resolvedPersona.isBuiltIn) {
397+
if (resolvedPersona) {
399398
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
400399
return;
401400
}
@@ -567,7 +566,7 @@ export function UserProfilePanel({
567566
);
568567

569568
const handleEditPersona = React.useCallback(() => {
570-
if (!resolvedPersona || resolvedPersona.isBuiltIn) return;
569+
if (!resolvedPersona) return;
571570
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
572571
}, [resolvedPersona]);
573572

@@ -737,8 +736,7 @@ export function UserProfilePanel({
737736
resolvedPersona,
738737
);
739738
const canManagePersona = isOwner === true && resolvedPersona !== undefined;
740-
const canEditPersona =
741-
canManagePersona && resolvedPersona?.isBuiltIn !== true;
739+
const canEditPersona = canManagePersona;
742740
const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam;
743741
const archiveActions = useIdentityArchive(effectivePubkey);
744742
const agentSettingsMenu = (

‎desktop/src/testing/e2eBridge.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6691,10 +6691,6 @@ async function handleUpdatePersona(args: {
66916691
if (!persona) {
66926692
throw new Error(`agent ${args.input.id} not found`);
66936693
}
6694-
if (persona.is_builtin) {
6695-
throw new Error("Built-in agents cannot be edited.");
6696-
}
6697-
66986694
persona.display_name = args.input.displayName.trim();
66996695
persona.avatar_url = args.input.avatarUrl?.trim() || null;
67006696
persona.system_prompt = args.input.systemPrompt.trim();

‎desktop/tests/e2e/agents.spec.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,45 @@ test("built-in personas are used from the catalog dialog", async ({ page }) => {
228228
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
229229
});
230230

231+
test("built-in persona edits persist", async ({ page }) => {
232+
await installMockBridge(page, {
233+
activePersonaIds: ["builtin:fizz"],
234+
globalAgentConfig: {
235+
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
236+
provider: "anthropic",
237+
model: "claude-opus-4-5",
238+
},
239+
});
240+
await gotoApp(page);
241+
await page.getByTestId("open-agents-view").click();
242+
243+
await page.getByLabel("Open actions for Fizz").click();
244+
await page.getByRole("menuitem", { name: "Edit" }).click();
245+
246+
const dialog = page.getByTestId("persona-dialog");
247+
await dialog.getByLabel("Agent name").fill("My Fizz");
248+
await dialog.getByLabel("Agent instruction").fill("User-edited instructions");
249+
await dialog.getByRole("button", { name: "LLM provider" }).click();
250+
await page
251+
.getByRole("menuitemradio", { name: "Anthropic", exact: true })
252+
.click();
253+
await dialog.getByRole("button", { name: "Save changes" }).click();
254+
255+
await expect(dialog).toHaveCount(0);
256+
await expect(page.getByTestId("agents-library-personas")).toContainText(
257+
"My Fizz",
258+
);
259+
const personas = await invokeTauri<
260+
Array<{ id: string; display_name: string; system_prompt: string }>
261+
>(page, "list_personas");
262+
expect(
263+
personas.find((persona) => persona.id === "builtin:fizz"),
264+
).toMatchObject({
265+
display_name: "My Fizz",
266+
system_prompt: "User-edited instructions",
267+
});
268+
});
269+
231270
test("agent avatar emoji picker scrolls inside its popover", async ({
232271
page,
233272
}) => {

0 commit comments

Comments
 (0)