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);