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.
This commit is contained in:
Alessandro 2026-08-11 16:25:42 +02:00
parent 13ecab24ee
commit c327701c83
9 changed files with 67 additions and 16 deletions

View file

@ -68,7 +68,8 @@
row exposes scoped availability, duplication, restore for inherited profiles,
icon-only Edit, and Delete for profiles owned by that scope. Duplicate
materializes the effective source profile into the selected writable layer
with a collision-free ID and title.
with a collision-free ID and title. Restore visibility follows the sparse
removal plan, ignores unrelated profile files, and reports successful reset.
Availability changes quietly refresh the adjacent profile switcher catalog
without a success toast.
- The same project selector is available inside Create and Edit. Create keeps

View file

@ -166,12 +166,6 @@ def _profile_root(profile_id: str, project_name: str = "") -> Path:
)
def _scope_has_files(root: Path) -> bool:
return root.is_dir() and any(
path.is_file() or path.is_symlink() for path in root.rglob("*")
)
def _scope_owns_custom_profile(profile_id: str, project_name: str) -> bool:
if not _profile_root(profile_id, project_name).is_dir():
return False
@ -222,7 +216,10 @@ def list_profiles(context: Any | None = None) -> list[dict[str, Any]]:
"origin": state["origin"],
"origin_chain": state["origin_chain"],
"built_in": state["built_in"],
"scope_has_overrides": state["scope_has_overrides"],
"scope_has_overrides": bool(
profile_exists(profile_id, context)
and plan_remove_changes(profile_id, context).changes
),
"deletable": state["deletable"],
"avatar": state["avatar"]["effective"],
"avatar_url": effective_avatar_url(profile_id, context),
@ -348,7 +345,10 @@ def build_profile_state(
"origin": metadata.pop("origin"),
"origin_chain": metadata.pop("origin_chain"),
"built_in": metadata.pop("built_in"),
"scope_has_overrides": metadata.pop("scope_has_overrides"),
"scope_has_overrides": bool(
profile_exists(profile_id, context)
and plan_remove_changes(profile_id, context).changes
),
"deletable": metadata.pop("deletable"),
"metadata": metadata,
"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
"inherited_source": _relative_source(inherited_source),
}
scope_root = _profile_root(profile_id, project_name)
built_in = (Path(files.get_abs_path("agents")) / profile_id).is_dir()
plugin_origin = any(layer.kind == "plugin" for layer in layers)
state.update(
@ -386,7 +385,6 @@ def metadata_state(profile_id: str, context: Any | None = None) -> dict[str, Any
"origin": "Built-in" if built_in else "Plugin" if plugin_origin else "Custom",
"origin_chain": list(dict.fromkeys(layer.kind for layer in layers)),
"built_in": built_in,
"scope_has_overrides": _scope_has_files(scope_root),
"deletable": _scope_owns_custom_profile(profile_id, project_name),
}
)

View file

@ -346,6 +346,10 @@ const model = {
...this.scopeInput(),
});
await this.loadProfiles();
globalThis.justToast?.(
`${profile.title || profile.id} reset to default.`,
"success", 1800, "agent-profile-reset",
);
await modelConfigStore.loadAgentProfiles(true);
if (editorState) {
await this.loadEditor(profile.id, false);
@ -1275,7 +1279,7 @@ const model = {
context_id: created.ctxid,
...(scopeProject ? { name: scopeProject } : {}),
});
await callJsonApi("/agent_profile_set", {
const selectedProfile = await callJsonApi("/agent_profile_set", {
context_id: created.ctxid,
agent_profile: profileId,
});
@ -1288,6 +1292,10 @@ const model = {
try { sessionStorage.setItem(READY_NOTE_KEY, created.ctxid); } catch {}
}
await chatsStore.selectChat(created.ctxid);
if (chatsStore.selectedContext) {
chatsStore.selectedContext.agent_profile = selectedProfile.agent_profile || profileId;
chatsStore.selectedContext.agent_profile_label = selectedProfile.agent_profile_label || profileId;
}
document.dispatchEvent(new CustomEvent("chat-created", { detail: { ctxid: created.ctxid } }));
return created.ctxid;
} catch (error) {

View file

@ -156,7 +156,7 @@
<div class="agent-field agent-model-preset">
<label for="agent-editor-easy-model-preset" class="agent-field-label">Model preset</label>
<div class="agent-model-preset-picker">
<select id="agent-editor-easy-model-preset" x-model="$store.agentEditor.draft.modelPreset">
<select id="agent-editor-easy-model-preset" x-model="$store.agentEditor.draft.modelPreset" x-effect="$nextTick(() => { $el.value = $store.agentEditor.draft.modelPreset })">
<option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
<template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
<option :value="preset.name" x-text="preset.name"></option>
@ -259,7 +259,7 @@
<div class="agent-field wide agent-model-preset">
<label for="agent-editor-model-preset" class="agent-field-label">Model preset</label>
<div class="agent-model-preset-picker">
<select id="agent-editor-model-preset" x-model="$store.agentEditor.draft.modelPreset">
<select id="agent-editor-model-preset" x-model="$store.agentEditor.draft.modelPreset" x-effect="$nextTick(() => { $el.value = $store.agentEditor.draft.modelPreset })">
<option value="" x-text="`Use current preset (${$store.agentEditor.state.model_preset.effective})`"></option>
<template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
<option :value="preset.name" x-text="preset.name"></option>

View file

@ -351,6 +351,15 @@ def test_plugin_configs_preserve_unowned_keys_and_use_json(user_root: Path) -> N
assert skill_data["active_skills"] == [{"name": "existing"}]
assert skill_data["visibility_policy"]["default"] == "block"
editor.apply_change_plan(editor.plan_remove_changes("researcher"))
assert json.loads(model.read_text()) == {"manual": 1}
assert json.loads(tools.read_text()) == {"manual": 2}
assert json.loads(skill.read_text()) == {
"active_skills": [{"name": "existing"}]
}
assert editor.build_profile_state("researcher")["scope_has_overrides"] is False
def test_model_and_off_tool_choices_write_only_their_json_contracts(
user_root: Path,

View file

@ -75,6 +75,7 @@ def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls
assert "No optional tools" not in modal
assert 'class="agent-model-preset-picker"' in modal
assert modal.count('x-model="$store.agentEditor.draft.modelPreset"') == 2
assert modal.count('$el.value = $store.agentEditor.draft.modelPreset') == 2
assert 'id="agent-editor-easy-model-preset"' in easy_surface
easy_identity = easy_surface[easy_surface.index('<section class="agent-easy-identity">'):easy_surface.index('<section class="agent-easy-field">')]
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() ->
harness = r"""
const calls = [];
const confirmations = [];
const toasts = [];
let setEnabledHandler = null;
let loadHandler = null;
let profilesHandler = null;
let confirmResult = false;
const createStore = (_name, value) => value;
const callJsonApi = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (payload?.action === "load" && loadHandler) return loadHandler(payload);
if (payload?.action === "list") return profilesHandler
? profilesHandler(payload)
: { ok: true, profiles: [] };
if (payload?.action === "set_enabled") return setEnabledHandler
? setEnabledHandler(payload)
: { ok: true, active_profile: "default", active_profile_label: "Default" };
@ -328,6 +334,11 @@ const callJsonApi = async (endpoint, payload) => {
title: "Researcher 1",
profiles: [{ id: "researcher" }, { id: "researcher-1" }],
};
if (endpoint === "/agent_profile_set") return {
ok: true,
agent_profile: payload.agent_profile,
agent_profile_label: "Researcher",
};
return endpoint === "/chat_create" ? { ok: true, ctxid: "fresh-chat" } : { ok: true };
};
const fetchApi = async () => ({ ok: true, json: async () => ({}) });
@ -337,7 +348,11 @@ const showConfirmDialog = async options => { confirmations.push(options); return
const chatsStore = {
selected: "old-chat",
selectedContext: { project: { name: "demo" }, agent_profile: "researcher" },
selectChat: async (id) => calls.push({ endpoint: "selectChat", payload: id }),
selectChat: async (id) => {
calls.push({ endpoint: "selectChat", payload: id });
chatsStore.selected = id;
chatsStore.selectedContext = { agent_profile: "default", agent_profile_label: "Default" };
},
};
const modelConfigStore = {
loadAgentProfiles: async force => calls.push({ endpoint: "loadAgentProfiles", payload: force }),
@ -382,6 +397,7 @@ globalThis.sessionStorage = { setItem: () => {}, getItem: () => "", removeItem:
globalThis.localStorage = { setItem: () => {}, getItem: () => "" };
globalThis.requestAnimationFrame = callback => callback();
globalThis.confirm = () => true;
globalThis.justToast = (...args) => toasts.push(args);
"""
checks = r"""
if (slugifyProfileName(" Crème Brûlée__Lab ") !== "creme-brulee-lab") throw new Error("slug mismatch");
@ -519,9 +535,15 @@ confirmResult = false;
await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
if (calls.length || confirmations.at(-1)?.title !== "Reset Researcher to default?") throw new Error("restore cancellation failed");
confirmResult = true;
profilesHandler = () => ({
ok: true,
profiles: [{ id: "researcher", title: "Researcher", scope_has_overrides: false }],
});
await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
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");
if (!calls.some(item => item.endpoint === "loadAgentProfiles" && item.payload === true) || store.saving) throw new Error("reset to default did not refresh profile state");
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");
profilesHandler = null;
let editorReload = null;
const loadEditor = store.loadEditor;
const setMode = store.setMode;
@ -716,6 +738,7 @@ if (calls[1].payload.action !== "deactivate") throw new Error("global test chat
if (calls[2].payload.agent_profile !== "researcher") throw new Error("profile not selected");
if (calls[3].payload.action !== "clear") throw new Error("chat preset override not cleared");
if (store.readyNoteContext !== "fresh-chat") throw new Error("ready note missing");
if (chatsStore.selectedContext.agent_profile !== "researcher" || chatsStore.selectedContext.agent_profile_label !== "Researcher") throw new Error("fresh chat showed a stale profile");
calls.length = 0;
store.projectName = "demo";
await store.openFreshChat("researcher", false);

View file

@ -69,6 +69,14 @@ const chats = [
{{ id: "c", created_at: 10 }},
];
reset(chats, "b");
globalThis.__context = "a";
await model.selectChat("a");
assert(
model.selected === "a" && model.selectedContext?.id === "a",
"selection state must catch up when the low-level context already switched",
);
let resolveDelete;
globalThis.__sendJsonData = () => new Promise((resolve) => {{ resolveDelete = resolve; }});
reset(chats, "a");

View file

@ -31,6 +31,7 @@
- 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.
- 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.
- 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.
- Chat selection must synchronize the sidebar store even when the low-level context has already switched to the requested ID.
## Work Guidance

View file

@ -124,7 +124,10 @@ const model = {
if (!id || this.deletedContextIds[id]) return;
const currentContext = getContext();
if (id === currentContext) return; // already selected
if (id === currentContext) {
this.setSelected(id);
return;
}
// Proceed with context selection
setContext(id);