Fix Agent Editor profile lifecycle state
Base reset visibility on the sparse removal plan, preserve unrelated profile files, and surface completion feedback without waiting for model-profile refreshes. Keep saved preset controls and fresh-chat selection synchronized across global and project scopes, including the shared sidebar context race, with focused regressions and updated DOX.
Alessandro committed
Aug 11, 2026 at 16:25 UTC
c327701c8330d9695b4e2f74c9401530aa304086
9 files changed
+67
-16
plugins/_agent_editor/AGENTS.md
+2
-1
@@ -68,7 +68,8 @@
68
row exposes scoped availability, duplication, restore for inherited profiles,
69
icon-only Edit, and Delete for profiles owned by that scope. Duplicate
70
materializes the effective source profile into the selected writable layer
71
- with a collision-free ID and title.
71
+ with a collision-free ID and title. Restore visibility follows the sparse
72
+ removal plan, ignores unrelated profile files, and reports successful reset.
73
Availability changes quietly refresh the adjacent profile switcher catalog
74
without a success toast.
75
- The same project selector is available inside Create and Edit. Create keeps
plugins/_agent_editor/helpers/editor.py
+8
-10
@@ -166,12 +166,6 @@ def _profile_root(profile_id: str, project_name: str = "") -> Path:
166
)
167
168
169
-def _scope_has_files(root: Path) -> bool:
170
- return root.is_dir() and any(
171
- path.is_file() or path.is_symlink() for path in root.rglob("*")
172
- )
173
-
174
-
169
def _scope_owns_custom_profile(profile_id: str, project_name: str) -> bool:
170
if not _profile_root(profile_id, project_name).is_dir():
171
return False
@@ -222,7 +216,10 @@ def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
216
"origin": state["origin"],
217
"origin_chain": state["origin_chain"],
218
"built_in": state["built_in"],
225
- "scope_has_overrides": state["scope_has_overrides"],
219
+ "scope_has_overrides": bool(
220
+ profile_exists(profile_id, context)
221
+ and plan_remove_changes(profile_id, context).changes
222
+ ),
223
"deletable": state["deletable"],
224
"avatar": state["avatar"]["effective"],
225
"avatar_url": effective_avatar_url(profile_id, context),
@@ -348,7 +345,10 @@ def build_profile_state(
345
"origin": metadata.pop("origin"),
346
"origin_chain": metadata.pop("origin_chain"),
347
"built_in": metadata.pop("built_in"),
351
- "scope_has_overrides": metadata.pop("scope_has_overrides"),
348
+ "scope_has_overrides": bool(
349
+ profile_exists(profile_id, context)
350
+ and plan_remove_changes(profile_id, context).changes
351
+ ),
352
"deletable": metadata.pop("deletable"),
353
"metadata": metadata,
354
"avatar_url": effective_avatar_url(profile_id, context),
@@ -378,7 +378,6 @@ def metadata_state(profile_id: str, context: Any | None = None) -> dict[str, Any
378
"inherited_source": _relative_source(inherited_source),
379
}
380
381
- scope_root = _profile_root(profile_id, project_name)
381
built_in = (Path(files.get_abs_path("agents")) / profile_id).is_dir()
382
plugin_origin = any(layer.kind == "plugin" for layer in layers)
383
state.update(
@@ -386,7 +385,6 @@ def metadata_state(profile_id: str, context: Any | None = None) -> dict[str, Any
385
"origin": "Built-in" if built_in else "Plugin" if plugin_origin else "Custom",
386
"origin_chain": list(dict.fromkeys(layer.kind for layer in layers)),
387
"built_in": built_in,
389
- "scope_has_overrides": _scope_has_files(scope_root),
388
"deletable": _scope_owns_custom_profile(profile_id, project_name),
389
}
390
)
plugins/_agent_editor/webui/agent-editor-store.js
+9
-1
@@ -346,6 +346,10 @@ const model = {
346
...this.scopeInput(),
347
});
348
await this.loadProfiles();
349
+ globalThis.justToast?.(
350
+ `${profile.title || profile.id} reset to default.`,
351
+ "success", 1800, "agent-profile-reset",
352
+ );
353
await modelConfigStore.loadAgentProfiles(true);
354
if (editorState) {
355
await this.loadEditor(profile.id, false);
@@ -1275,7 +1279,7 @@ const model = {
1279
context_id: created.ctxid,
1280
...(scopeProject ? { name: scopeProject } : {}),
1281
});
1278
- await callJsonApi("/agent_profile_set", {
1282
+ const selectedProfile = await callJsonApi("/agent_profile_set", {
1283
context_id: created.ctxid,
1284
agent_profile: profileId,
1285
});
@@ -1288,6 +1292,10 @@ const model = {
1292
try { sessionStorage.setItem(READY_NOTE_KEY, created.ctxid); } catch {}
1293
}
1294
await chatsStore.selectChat(created.ctxid);
1295
+ if (chatsStore.selectedContext) {
1296
+ chatsStore.selectedContext.agent_profile = selectedProfile.agent_profile || profileId;
1297
+ chatsStore.selectedContext.agent_profile_label = selectedProfile.agent_profile_label || profileId;
1298
+ }
1299
document.dispatchEvent(new CustomEvent("chat-created", { detail: { ctxid: created.ctxid } }));
1300
return created.ctxid;
1301
} catch (error) {
plugins/_agent_editor/webui/main.html
+2
-2
@@ -156,7 +156,7 @@
156
<div class="agent-field agent-model-preset">
157
<label for="agent-editor-easy-model-preset" class="agent-field-label">Model preset</label>
158
<div class="agent-model-preset-picker">
159
- <select id="agent-editor-easy-model-preset" x-model="$store.agentEditor.draft.modelPreset">
159
+ <select id="agent-editor-easy-model-preset" x-model="$store.agentEditor.draft.modelPreset" x-effect="$nextTick(() => { $el.value = $store.agentEditor.draft.modelPreset })">
160
<option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
161
<template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
162
<option :value="preset.name" x-text="preset.name"></option>
@@ -259,7 +259,7 @@
259
<div class="agent-field wide agent-model-preset">
260
<label for="agent-editor-model-preset" class="agent-field-label">Model preset</label>
261
<div class="agent-model-preset-picker">
262
- <select id="agent-editor-model-preset" x-model="$store.agentEditor.draft.modelPreset">
262
+ <select id="agent-editor-model-preset" x-model="$store.agentEditor.draft.modelPreset" x-effect="$nextTick(() => { $el.value = $store.agentEditor.draft.modelPreset })">
263
<option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
264
<template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
265
<option :value="preset.name" x-text="preset.name"></option>
tests/test_agent_editor.py
+9
@@ -351,6 +351,15 @@ def test_plugin_configs_preserve_unowned_keys_and_use_json(user_root: Path) -> N
351
assert skill_data["active_skills"] == [{"name": "existing"}]
352
assert skill_data["visibility_policy"]["default"] == "block"
353
354
+ editor.apply_change_plan(editor.plan_remove_changes("researcher"))
355
+
356
+ assert json.loads(model.read_text()) == {"manual": 1}
357
+ assert json.loads(tools.read_text()) == {"manual": 2}
358
+ assert json.loads(skill.read_text()) == {
359
+ "active_skills": [{"name": "existing"}]
360
+ }
361
+ assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
362
+
363
364
def test_model_and_off_tool_choices_write_only_their_json_contracts(
365
user_root: Path,
tests/test_agent_editor_webui.py
+24
-1
@@ -75,6 +75,7 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
75
assert "No optional tools" not in modal
76
assert 'class="agent-model-preset-picker"' in modal
77
assert modal.count('x-model="$store.agentEditor.draft.modelPreset"') == 2
78
+ assert modal.count('$el.value = $store.agentEditor.draft.modelPreset') == 2
79
assert 'id="agent-editor-easy-model-preset"' in easy_surface
80
easy_identity = easy_surface[easy_surface.index('<section class="agent-easy-identity">'):easy_surface.index('<section class="agent-easy-field">')]
81
assert easy_identity.index('id="agent-editor-name"') < easy_identity.index('id="agent-editor-easy-model-preset"')
@@ -304,13 +305,18 @@ def test_local_slugging_and_fresh_chat_profile_selection_are_deterministic() ->
305
harness = r"""
306
const calls = [];
307
const confirmations = [];
308
+const toasts = [];
309
let setEnabledHandler = null;
310
let loadHandler = null;
311
+let profilesHandler = null;
312
let confirmResult = false;
313
const createStore = (_name, value) => value;
314
const callJsonApi = async (endpoint, payload) => {
315
calls.push({ endpoint, payload });
316
if (payload?.action === "load" && loadHandler) return loadHandler(payload);
317
+ if (payload?.action === "list") return profilesHandler
318
+ ? profilesHandler(payload)
319
+ : { ok: true, profiles: [] };
320
if (payload?.action === "set_enabled") return setEnabledHandler
321
? setEnabledHandler(payload)
322
: { ok: true, active_profile: "default", active_profile_label: "Default" };
@@ -328,6 +334,11 @@ const callJsonApi = async (endpoint, payload) => {
334
title: "Researcher 1",
335
profiles: [{ id: "researcher" }, { id: "researcher-1" }],
336
};
337
+ if (endpoint === "/agent_profile_set") return {
338
+ ok: true,
339
+ agent_profile: payload.agent_profile,
340
+ agent_profile_label: "Researcher",
341
+ };
342
return endpoint === "/chat_create" ? { ok: true, ctxid: "fresh-chat" } : { ok: true };
343
};
344
const fetchApi = async () => ({ ok: true, json: async () => ({}) });
@@ -337,7 +348,11 @@ const showConfirmDialog = async options => { confirmations.push(options); return
348
const chatsStore = {
349
selected: "old-chat",
350
selectedContext: { project: { name: "demo" }, agent_profile: "researcher" },
340
- selectChat: async (id) => calls.push({ endpoint: "selectChat", payload: id }),
351
+ selectChat: async (id) => {
352
+ calls.push({ endpoint: "selectChat", payload: id });
353
+ chatsStore.selected = id;
354
+ chatsStore.selectedContext = { agent_profile: "default", agent_profile_label: "Default" };
355
+ },
356
};
357
const modelConfigStore = {
358
loadAgentProfiles: async force => calls.push({ endpoint: "loadAgentProfiles", payload: force }),
@@ -382,6 +397,7 @@ globalThis.sessionStorage = { setItem: () => {}, getItem: () => "", removeItem:
397
globalThis.localStorage = { setItem: () => {}, getItem: () => "" };
398
globalThis.requestAnimationFrame = callback => callback();
399
globalThis.confirm = () => true;
400
+globalThis.justToast = (...args) => toasts.push(args);
401
"""
402
checks = r"""
403
if (slugifyProfileName(" Crème Brûlée__Lab ") !== "creme-brulee-lab") throw new Error("slug mismatch");
@@ -519,9 +535,15 @@ confirmResult = false;
535
await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
536
if (calls.length || confirmations.at(-1)?.title !== "Reset Researcher to default?") throw new Error("restore cancellation failed");
537
confirmResult = true;
538
+profilesHandler = () => ({
539
+ ok: true,
540
+ profiles: [{ id: "researcher", title: "Researcher", scope_has_overrides: false }],
541
+});
542
await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
543
if (!calls.some(item => item.payload?.action === "remove_changes" && item.payload.profile_id === "researcher" && item.payload.destructive === false)) throw new Error("reset to default did not use sparse removal");
544
if (!calls.some(item => item.endpoint === "loadAgentProfiles" && item.payload === true) || store.saving) throw new Error("reset to default did not refresh profile state");
545
+if (store.profiles[0]?.scope_has_overrides || toasts.at(-1)?.[0] !== "Researcher reset to default.") throw new Error("reset to default kept its action or omitted feedback");
546
+profilesHandler = null;
547
let editorReload = null;
548
const loadEditor = store.loadEditor;
549
const setMode = store.setMode;
@@ -716,6 +738,7 @@ if (calls[1].payload.action !== "deactivate") throw new Error("global test chat
738
if (calls[2].payload.agent_profile !== "researcher") throw new Error("profile not selected");
739
if (calls[3].payload.action !== "clear") throw new Error("chat preset override not cleared");
740
if (store.readyNoteContext !== "fresh-chat") throw new Error("ready note missing");
741
+if (chatsStore.selectedContext.agent_profile !== "researcher" || chatsStore.selectedContext.agent_profile_label !== "Researcher") throw new Error("fresh chat showed a stale profile");
742
calls.length = 0;
743
store.projectName = "demo";
744
await store.openFreshChat("researcher", false);
tests/test_webui_chat_deletion.py
+8
@@ -69,6 +69,14 @@ const chats = [
69
{{ id: "c", created_at: 10 }},
70
];
71
72
+reset(chats, "b");
73
+globalThis.__context = "a";
74
+await model.selectChat("a");
75
+assert(
76
+ model.selected === "a" && model.selectedContext?.id === "a",
77
+ "selection state must catch up when the low-level context already switched",
78
+);
79
+
80
let resolveDelete;
81
globalThis.__sendJsonData = () => new Promise((resolve) => {{ resolveDelete = resolve; }});
82
reset(chats, "a");
webui/components/sidebar/AGENTS.md
+1
@@ -31,6 +31,7 @@
31
- Process-detail preference changes must use the message renderer's async expansion hooks and honor an explicit chat-history render target so staged pages are ready before an atomic swap.
32
- The utility-message preference controls both individual utility steps and utility-only process-group chrome so hidden utility runs cannot leave empty headers in the transcript.
33
- Chat deletion removes the sidebar row optimistically in the same render batch as fallback selection. Keep successful local deletion tombstones for the page session so out-of-order poll or push snapshots cannot reinsert rows; restore the row and clear its tombstone if the delete request fails.
34
+- Chat selection must synchronize the sidebar store even when the low-level context has already switched to the requested ID.
35
36
## Work Guidance
37
webui/components/sidebar/chats/chats-store.js
+4
-1
@@ -124,7 +124,10 @@ const model = {
124
if (!id || this.deletedContextIds[id]) return;
125
126
const currentContext = getContext();
127
- if (id === currentContext) return; // already selected
127
+ if (id === currentContext) {
128
+ this.setSelected(id);
129
+ return;
130
+ }
131
132
// Proceed with context selection
133
setContext(id);