main
py 868 lines 54.9 KB
Raw
1 from __future__ import annotations
2
3 import base64
4 from pathlib import Path
5 import re
6 import shutil
7 import subprocess
8
9 import pytest
10
11
12 ROOT = Path(__file__).resolve().parents[1]
13 STORE = ROOT / "plugins" / "_agent_editor" / "webui" / "agent-editor-store.js"
14 MODAL = ROOT / "plugins" / "_agent_editor" / "webui" / "main.html"
15 SWITCHER = (
16 ROOT
17 / "plugins"
18 / "_model_config"
19 / "extensions"
20 / "webui"
21 / "chat-input-progress-start"
22 / "model-switcher.html"
23 )
24 SWITCHER_MIXIN = ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.js"
25
26
27 def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls() -> None:
28 modal = MODAL.read_text(encoding="utf-8")
29 store = STORE.read_text(encoding="utf-8")
30 switcher = SWITCHER.read_text(encoding="utf-8")
31 tool_section = re.search(
32 r'data-agent-editor-section="3".*?(?=<section x-show="\$store\.agentEditor\.section === \'4\'")',
33 modal,
34 re.DOTALL,
35 ).group(0)
36 mcp_section = re.search(
37 r'data-agent-editor-section="4".*?(?=<section x-show="\$store\.agentEditor\.section === \'5\'")',
38 modal,
39 re.DOTALL,
40 ).group(0)
41 skill_section = re.search(
42 r'data-agent-editor-section="5".*?(?=<section x-show="\$store\.agentEditor\.section === \'6\'")',
43 modal,
44 re.DOTALL,
45 ).group(0)
46 easy_surface = modal.split('<div class="agent-advanced"', 1)[0]
47
48 assert "Create agent" not in switcher
49 assert "Manage agents" in switcher
50 assert '<div class="agent-profile-row"' in switcher
51 assert 'class="agent-profile-row-edit"' in switcher
52 assert ':aria-label="`Edit ${profile.label || profile.key}`"' in switcher
53 assert "profileId: profile.key" in switcher
54 assert "<span>Edit</span>" in switcher
55 assert "profile.customized" not in switcher
56 assert 'class="model-switcher-item agent-profile-edit"' not in switcher
57 assert "min-height: 24px" in re.search(
58 r"\.agent-profile-row-edit\s*\{([^}]*)\}", switcher
59 ).group(1)
60 assert "createAgentProfileChat" not in switcher
61 assert all(
62 label in modal
63 for label in (
64 "Identity",
65 "Prompt files",
66 "Tools",
67 "MCPs",
68 "Skills",
69 "Review",
70 "Save & test",
71 )
72 )
73 assert 'aria-label="Editor mode"' in modal
74 assert "Allow selected" not in modal and "Block selected" not in modal
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"')
82 assert easy_surface.index('id="agent-editor-easy-model-preset"') < easy_surface.index('id="agent-editor-instructions"')
83 assert '`Use current preset (${$store.agentEditor.state.model_preset.effective})`' in modal
84 assert 'x-for="preset in $store.agentEditor.state.model_presets"' in modal
85 assert modal.count("Edit Presets") == 2
86 assert "Manage presets" not in modal
87 assert "model-preset-row" not in modal
88 assert easy_surface.count('<details class="capability-accordion">') == 3
89 assert re.findall(r'<summary><span>(Choose individual (?:tools|MCPs|skills))</span><x-icon name="expand_more"></x-icon></summary>', easy_surface) == ["Choose individual tools", "Choose individual MCPs", "Choose individual skills"]
90 assert '<details class="capability-accordion" open' not in easy_surface
91 assert 'x-for="tool in $store.agentEditor.standardToolCatalog"' in easy_surface
92 assert 'x-for="skill in $store.agentEditor.skillCatalog"' in easy_surface
93 assert 'x-for="tool in $store.agentEditor.mcpCatalog"' in easy_surface
94 assert "Choose tools in Advanced" not in modal
95 assert "Use standard tool access" not in modal
96 assert "Use standard skill access" not in modal
97 assert 'class="policy-lists"' not in modal
98 assert "policy-transfer-actions" not in modal
99 assert tool_section.count('class="policy-group"') == 1
100 assert mcp_section.count('class="policy-group"') == 1
101 assert skill_section.count('class="policy-group"') == 1
102 assert '<label>Category ' not in tool_section
103 assert "indeterminate" not in modal
104 assert modal.count('class="policy-state-control" role="group"') == 6
105 assert modal.count(':aria-pressed="$store.agentEditor.policyItemState') == 18
106 assert '<details class="policy-description"' not in tool_section
107 assert '<details class="policy-description"' not in mcp_section
108 assert '<details class="policy-description"' not in skill_section
109 assert tool_section.count('class="policy-item-description"') == 1
110 assert mcp_section.count('class="policy-item-description"') == 1
111 assert skill_section.count('class="policy-item-description"') == 1
112 assert "Your changes override the built-in profile. The original files stay unchanged." in modal
113 assert 'x-model="$store.agentEditor.projectName"' in modal
114 assert 'x-init="$nextTick(() => $el.value = $store.agentEditor.projectName)"' in modal
115 assert '@change="$store.agentEditor.onScopeChanged()"' in modal
116 assert '<option value="">Global</option>' in modal
117 assert 'x-for="project in $store.agentEditor.projects"' in modal
118 assert 'x-show="profile.deletable"' in modal
119 assert 'class="agent-customized-indicator"' not in modal
120 assert 'class="button agent-manager-create"' in modal
121 assert 'class="active-agent-display"' in modal
122 assert 'class="button icon-button" title="Edit"' in modal
123 assert 'class="text-button agent-manager-inline-action"' in modal
124 inline_action_style = re.search(
125 r"\.agent-editor \.agent-manager-inline-action\s*\{([^}]*)\}", modal
126 ).group(1)
127 assert "color:var(--color-message-text)" in inline_action_style
128 assert ".agent-editor .agent-manager-inline-action:hover:not(:disabled)" in modal
129 assert ':disabled="!!$store.agentEditor.duplicatingProfile"' in modal
130 assert ':aria-label="`Duplicate ${profile.title || profile.id}`"' in modal
131 assert "$store.agentEditor.duplicateProfile(profile)" in modal
132 assert 'x-show="profile.scope_has_overrides && !profile.deletable"' in modal
133 assert ':aria-label="`Reset ${profile.title || profile.id} to default`"' in modal
134 assert "Restore original" not in modal
135 assert modal.count("Reset to default") >= 6
136 assert "$store.agentEditor.restoreProfile(profile)" in modal
137 assert "$store.agentEditor.restoreProfile($store.agentEditor.state.profile)" in modal
138 assert 'class="agent-editor-topbar-actions"' in modal
139 topbar = modal.split('class="agent-editor-topbar-actions"', 1)[1].split("</header>", 1)[0]
140 footer = modal.split('class="modal-footer agent-editor-footer"', 1)[1]
141 assert "Reset to default" not in topbar
142 assert "$store.agentEditor.restoreProfile($store.agentEditor.state.profile)" in footer
143 assert 'class="btn btn-cancel agent-reset-profile"' in footer
144 assert '<x-icon name="restore"></x-icon><span>Reset to default</span>' not in footer
145 assert "x-show=\"$store.agentEditor.draft && !$store.agentEditor.draft.creating" in footer
146 assert ':disabled="$store.agentEditor.saving || !$store.agentEditor.state?.profile?.scope_has_overrides"' in footer
147 avatar_reset = 'x-show="$store.agentEditor.state.profile.metadata.avatar.has_override || $store.agentEditor.draft.avatar" @click="$store.agentEditor.resetAvatar()">Reset</button>'
148 assert modal.count(avatar_reset) == 2
149 assert '@click="$store.agentEditor.resetAvatar()">Remove</button>' not in modal
150 assert 'class="btn btn-cancel" @click="window.closeModal?.()">Cancel</button>' not in modal
151 assert 'class="toggle agent-profile-availability"' in modal
152 assert ':disabled="$store.agentEditor.profileAvailabilitySaving"' in modal
153 assert "Default is always available" not in modal
154 assert "$store.agentEditor.setProfileEnabled(profile, $event.target.checked)" in modal
155 assert "agent-profile-availability" not in store
156 assert "activateProfile(profile.id)" not in modal
157 assert 'class="button cancel icon-button" x-show="profile.deletable"' in modal
158 assert "project_override_active" not in modal
159 assert "profile.origin === 'Custom'" not in modal
160 assert "Agent scope" not in modal
161 assert "Create agents and customize inherited profiles" not in modal
162 assert "Unavailable — kept in your settings" in modal
163 assert "Customize this file" not in modal
164 assert "Choose a file and edit its prompt." in modal
165 assert "inherited version next to your version" not in modal
166 assert 'role="tablist" aria-label="Prompt view"' not in modal
167 assert "Your version" not in modal and ">Compare</button>" not in modal
168 assert "Find in file" not in modal and "prompt-match-action" not in modal
169 assert "No prompt files match your search." in modal
170 assert "Saving will change exactly these files — nothing else." in modal
171 assert "Review & test" not in modal
172 assert "Refresh change plan" not in modal
173 assert "Back to Easy" not in modal
174 assert "This agent has a detailed prompt" not in modal
175 assert "Replace with simple instructions" not in modal
176 assert "easyInstructionsEditable" not in modal
177 assert '<textarea id="agent-editor-instructions"' in modal
178 assert "<h2" not in modal
179 assert "Section 1" not in modal
180 assert '<textarea id="agent-editor-description" rows="2"' in modal
181 assert "Allow newly installed" not in modal
182 assert modal.count("<strong>Allow tools by default</strong>") == 2
183 assert modal.count("<strong>Allow MCPs by default</strong>") == 2
184 assert modal.count("<strong>Allow skills by default</strong>") == 2
185 assert "Allow tools and MCPs by default" not in modal
186 assert "Applies to Tools and MCPs left on Default." not in modal
187 assert "Applies to Skills left on Default." not in modal
188 assert "No blocked tools" not in modal and "No blocked skills" not in modal
189 assert "policy-description" not in modal and "-webkit-line-clamp:2" not in modal
190 assert 'class="prompt-file-list" role="region" aria-label="Prompt file list" tabindex="0"' in modal
191 assert 'class="prompt-editor" role="region" aria-label="Selected prompt file" tabindex="0"' in modal
192 assert "Preview combined prompt" not in modal
193 assert 'class="prompt-customization-path"' in modal
194 assert '<strong x-text="$store.agentEditor.selectedPrompt"></strong>\n <div class="prompt-customization-path">' in modal
195 assert "source-chain" not in modal and "promptSourceChain" not in store
196 assert "$store.agentEditor.promptCustomizationPath()" in modal
197 assert 'class="review-identity"' in modal
198 assert 'aria-label="Agent identity"' in modal
199 assert 'id="review-identity-title"' not in modal
200 review_identity = modal[modal.index('class="review-identity"'):modal.index('class="change-plan"')]
201 assert "draft.profileId\"></code>" not in review_identity
202 assert "width:92vw" in modal
203 assert ".modal-inner.agent-editor-advanced .modal-scroll { max-height:none; }" in modal
204 assert '.agent-advanced-content > section[data-agent-editor-section="2"] { height:100%; min-height:0; }' in modal
205 assert ".prompt-workspace { flex:1 1 auto;" in modal
206 assert 'width:1.5rem; height:1.5rem' in modal
207 assert 'id="agent-editor-prompt-ace"' in modal
208 assert 'id="agent-editor-prompt-text"' not in modal
209 assert "globalThis.ace.edit(container)" in store
210 assert 'editor.session.setMode("ace/mode/markdown")' in store
211 assert "editor.session.setUseWrapMode(true)" in store
212 assert "showPrintMargin: false" in store and "useWorker: false" in store
213 assert "findInPrompt" not in store and "promptTextSearch" not in store
214 assert 'class="agent-editor-heading"' in modal
215 assert ':aria-invalid=' in modal
216 assert modal.count('role="alert"') >= 4
217 assert "Fix ${$store.agentEditor.validationIssues().length}" in modal
218 assert modal.count("$store.agentEditor.validationIssues().length > 0") == 2
219 assert "$store.agentEditor.view === 'editor' && !$store.agentEditor.pendingMutation" in modal
220 assert 'x-show="$store.agentEditor.view === \'editor\' && !$store.agentEditor.draft?.creating"' in modal
221 assert 'class="btn btn-ok" x-show="$store.agentEditor.view === \'editor\'"' in modal
222 assert "Delete all customizations in" in modal
223 assert '.agent-editor input[type="checkbox"]' not in modal
224 assert 'promptDisplayState(prompt)' in modal
225 assert "Will reset to default on save." in modal
226 assert "metadataProvenance('description')" not in modal
227 assert 'x-show="$store.agentEditor.metadataProvenance(\'title\')"' in modal
228 assert 'metadataProvenance(\'context\')' not in modal
229 assert ':title="prompt.filename"' not in modal
230 assert "promptEditPending($store.agentEditor.selectedPromptDraft)" in modal
231 assert 'aria-label="Discard current edit"' in modal
232 assert 'aria-label="Accept current edit"' in modal
233 assert ':readonly="!$store.agentEditor.isPromptEditing' not in modal
234 assert ".prompt-ace { flex:1; min-height:0;" in modal
235 store_source = STORE.read_text(encoding="utf-8")
236 assert "cannot be recovered" in store_source
237 assert "deletionImpactHtml" not in store_source
238 assert "agent-profile-avatar" in switcher
239 assert '<button type="button" class="model-switcher-item agent-profile-item"' in switcher
240 assert '<div class="model-switcher-item agent-profile-item"' not in switcher
241 switcher_mixin = SWITCHER_MIXIN.read_text(encoding="utf-8")
242 assert "avatar_url" in switcher_mixin
243 assert "BUILT_IN_AGENT_COLORS" in switcher_mixin
244 assert '!["_example", "default"].includes(profile.id)' in switcher_mixin
245 assert 'activeKey !== "default"' in switcher_mixin
246 assert "customized: !!profile.has_user_overrides" not in switcher_mixin
247 assert 'x-for="profile in $store.agentEditor.visibleProfiles"' in modal
248 assert "$store.agentEditor.activeProfile().id !== 'default'" in modal
249 assert 'name="palette"' in modal and 'name="add_photo_alternate"' in modal
250 assert ".capability-accordion .policy-items { max-height:none; overflow:visible;" in modal
251 assert ".capability-policy-group + .capability-policy-group { border-top:1px solid var(--color-border);" in modal
252 assert "font-weight:500" in re.search(
253 r"\.capability-accordion summary\s*\{([^}]*)\}", modal
254 ).group(1)
255 assert ".capability-accordion[open] summary x-icon { transform:rotate(180deg); }" in modal
256 assert "toolCategory" not in store_source
257 assert "selectedAllowed" not in store_source and "selectedBlocked" not in store_source
258 assert "moveAllVisible" not in store_source and "confirmBulkMove" not in store_source
259 assert "setPolicyItemState" in store_source and "collapsePolicy" in store_source
260 assert '.agent-editor [aria-invalid="true"]' not in modal
261 assert "color-scheme:dark" not in modal
262 assert "#fff 82%" not in modal
263 assert ".agent-manager-name strong,.agent-manager-copy p { overflow-wrap:anywhere; }" in modal
264 assert ".field-error { display:block; color:var(--color-text)" in modal
265 assert ".prompt-pane" not in modal
266 assert 'callJsonApi("/plugins/_agent_editor/agent_editor"' in switcher_mixin
267 assert "profile.enabled !== false" in switcher_mixin
268 assert 'x-show="!$store.modelConfig.agentProfilesLoading"' in switcher
269 assert "@keydown.ctrl.s.prevent" in modal
270 assert "@media (max-width: 760px)" in modal
271 assert "tri-state-item" not in modal
272 assert "policy-state-legend" not in modal
273 assert "policy-item-state" not in modal
274 assert modal.count("Default (${$store.agentEditor.policyDefault") == 6
275 assert "border-radius:var(--border-radius-sm)" in modal
276 assert "cyclePolicyItem" not in store_source and "policyAriaChecked" not in store_source
277 assert ".agent-manager-card { grid-template-columns:3rem minmax(0,1fr) auto; align-items:start; }" in modal
278 assert ".agent-manager-actions { grid-column:auto; align-self:stretch; display:grid;" in modal
279 assert ".agent-manager-actions .agent-profile-availability { grid-column:1/-1; }" in modal
280 assert ".footer-actions { width:auto; max-width:100%; flex-wrap:wrap; justify-content:flex-end; }" in modal
281 assert ".footer-actions .btn { flex:0 1 auto; text-align:center; }" in modal
282 assert modal.count("data-modal-footer") == 1
283 assert ">Close</button>" not in modal
284
285
286 def test_agent_editor_store_has_no_conversational_or_model_builder_path() -> None:
287 source = STORE.read_text(encoding="utf-8")
288 modal = MODAL.read_text(encoding="utf-8")
289 switcher_source = (
290 ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.js"
291 ).read_text(encoding="utf-8")
292
293 assert 'createStore("agentEditor", model)' in source
294 assert "CREATE_AGENT_PROFILE_PROMPT" not in switcher_source
295 assert "a0-create-agent" not in switcher_source
296 assert "save_agent_data" not in source
297 assert not re.search(r"utility.?model|call.?model|generate", source, re.IGNORECASE)
298 assert "Advanced <span" not in modal
299 assert 'class="toast-action-row"' in source
300 assert 'class="button confirm"' in source
301 assert "toast-link" not in source and ".toast-link" not in modal
302
303
304 @pytest.mark.skipif(not shutil.which("node"), reason="node is required")
305 def test_local_slugging_and_fresh_chat_profile_selection_are_deterministic() -> None:
306 source = STORE.read_text(encoding="utf-8")
307 source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
308 harness = r"""
309 const calls = [];
310 const confirmations = [];
311 const toasts = [];
312 let setEnabledHandler = null;
313 let loadHandler = null;
314 let profilesHandler = null;
315 let confirmResult = false;
316 const createStore = (_name, value) => value;
317 const callJsonApi = async (endpoint, payload) => {
318 calls.push({ endpoint, payload });
319 if (payload?.action === "load" && loadHandler) return loadHandler(payload);
320 if (payload?.action === "list") return profilesHandler
321 ? profilesHandler(payload)
322 : { ok: true, profiles: [] };
323 if (payload?.action === "set_enabled") return setEnabledHandler
324 ? setEnabledHandler(payload)
325 : { ok: true, active_profile: "default", active_profile_label: "Default" };
326 if (payload?.action === "plan") return {
327 ok: true,
328 written: [payload.project_name
329 ? `usr/projects/${payload.project_name}/.a0proj/agents/${payload.patch.profile_id}/agent.yaml`
330 : `usr/agents/${payload.patch.profile_id}/agent.yaml`],
331 deleted: [],
332 warnings: [],
333 };
334 if (payload?.action === "duplicate") return {
335 ok: true,
336 profile_id: "researcher-1",
337 title: "Researcher 1",
338 profiles: [{ id: "researcher" }, { id: "researcher-1" }],
339 };
340 if (endpoint === "/agent_profile_set") return {
341 ok: true,
342 agent_profile: payload.agent_profile,
343 agent_profile_label: "Researcher",
344 };
345 return endpoint === "/chat_create" ? { ok: true, ctxid: "fresh-chat" } : { ok: true };
346 };
347 const fetchApi = async () => ({ ok: true, json: async () => ({}) });
348 const closeModal = async () => {};
349 const openModal = async () => {};
350 const showConfirmDialog = async options => { confirmations.push(options); return confirmResult; };
351 const chatsStore = {
352 selected: "old-chat",
353 selectedContext: { project: { name: "demo" }, agent_profile: "researcher" },
354 selectChat: async (id) => {
355 calls.push({ endpoint: "selectChat", payload: id });
356 chatsStore.selected = id;
357 chatsStore.selectedContext = { agent_profile: "default", agent_profile_label: "Default" };
358 },
359 };
360 const modelConfigStore = {
361 loadAgentProfiles: async force => calls.push({ endpoint: "loadAgentProfiles", payload: force }),
362 openPresetEditor: async preset => calls.push({ endpoint: "openPresetEditor", payload: preset }),
363 selectAgentProfile: async (contextId, profileId) => {
364 calls.push({ endpoint: "selectAgentProfile", payload: { contextId, profileId } });
365 return true;
366 },
367 getAgentProfileVisual: (_id, label) => ({ color: "#123456", url: "", initials: label?.[0] || "A" }),
368 };
369 const aceState = { change: null, find: null, destroyed: false, value: "" };
370 const aceContainer = { textContent: "" };
371 const aceSession = {
372 setMode: value => { aceState.mode = value; },
373 setUseWrapMode: value => { aceState.wrap = value; },
374 on: (name, callback) => { if (name === "change") aceState.change = callback; },
375 off: (name, callback) => { if (name === "change" && aceState.change === callback) aceState.change = null; },
376 };
377 const aceEditor = {
378 container: aceContainer,
379 session: aceSession,
380 textInput: { getElement: () => ({ setAttribute: (key, value) => { aceState[key] = value; } }) },
381 setTheme: value => { aceState.theme = value; },
382 setOptions: value => { aceState.options = value; },
383 setValue: value => { aceState.value = value; aceState.change?.(); },
384 getValue: () => aceState.value,
385 find: (query, options) => { aceState.find = { query, options }; },
386 focus: () => { aceState.focused = true; },
387 resize: () => { aceState.resized = true; },
388 destroy: () => { aceState.destroyed = true; },
389 };
390 globalThis.ace = { edit: container => { aceState.container = container; return aceEditor; } };
391 globalThis.window = globalThis;
392 globalThis.document = {
393 dispatchEvent: (event) => calls.push({ endpoint: "event", payload: event.type }),
394 createElement: () => ({ textContent: "", get innerHTML() { return this.textContent; } }),
395 addEventListener: () => {},
396 removeEventListener: () => {},
397 };
398 globalThis.CustomEvent = class { constructor(type) { this.type = type; } };
399 globalThis.sessionStorage = { setItem: () => {}, getItem: () => "", removeItem: () => {} };
400 globalThis.localStorage = { setItem: () => {}, getItem: () => "" };
401 globalThis.requestAnimationFrame = callback => callback();
402 globalThis.confirm = () => true;
403 globalThis.justToast = (...args) => toasts.push(args);
404 """
405 checks = r"""
406 if (slugifyProfileName(" Crème Brûlée__Lab ") !== "creme-brulee-lab") throw new Error("slug mismatch");
407 if (slugifyProfileName("東京") !== "") throw new Error("unsupported slug mismatch");
408 store.draft = { title: "stale" };
409 store.initialDraft = { title: "clean" };
410 store.intent = { view: "manage", contextId: "" };
411 const modalTitle = { textContent: "" };
412 const modalElement = { querySelector: selector => selector === ".modal-title" ? modalTitle : null };
413 const modalInner = { classList: { toggle: () => {} } };
414 await store.mount({
415 closest: selector => selector === ".modal" ? modalElement : selector === ".modal-inner" ? modalInner : null,
416 querySelector: () => null,
417 });
418 if (store.draft !== null || store.initialDraft !== null || store.dirty || store.loading) throw new Error("manage mount kept stale draft state");
419 if (modalTitle.textContent !== "Manage agents") throw new Error("manage mount title mismatch");
420 store.state = {
421 profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: {} } },
422 prompts: [
423 { filename: "agent.system.main.specifics.md", group: "2.1", group_label: "Agent instructions", effective: "", inherited: "", source_chain: [] },
424 { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Inherited comm", inherited: "Inherited comm", source_chain: ["Framework", "Researcher"], state: "Inherited", has_override: false },
425 ],
426 model_preset: { has_override: false },
427 tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
428 skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [] },
429 };
430 store.makeDraft(true);
431 if (await store.previewPlan()) throw new Error("invalid plan unexpectedly succeeded");
432 if (store.planStatus !== "blocked" || store.error || store.validationIssues().length !== 2) throw new Error("blocked plan state mismatch");
433 if (store.fieldIssue("name")?.message !== "Agent name is required.") throw new Error("inline name issue missing");
434 if (store.fieldIssue("instructions")?.field !== "agent-editor-instructions") throw new Error("Easy validation targeted the wrong input");
435 await store.save();
436 if (store.error) throw new Error("validation leaked into dismissible error banner");
437 store.state.profile.id = "new-agent";
438 store.state.profile.origin = "Built-in";
439 store.state.profile.metadata.title = { inherited_source: "agents/new-agent/agent.yaml" };
440 if (store.metadataProvenance("title") !== "") throw new Error("new profile showed misleading provenance");
441 store.draft.creating = false;
442 if (store.metadataProvenance("title") !== "Using the default") throw new Error("default provenance mismatch");
443 store.profiles = [{ id: "researcher", title: "Researcher" }, { id: "default", title: "Default" }];
444 if (store.visibleProfiles.length !== 1 || store.visibleProfiles[0].id !== "researcher") throw new Error("Default profile remained selectable in Agent Editor");
445 store.state.profile.metadata.title = { inherited_source: "agents/researcher/agent.yaml" };
446 if (store.metadataProvenance("title") !== "Inherited from Researcher") throw new Error("inherited provenance mismatch");
447 store.state.profile.metadata.title.has_override = true;
448 if (store.metadataProvenance("title") !== "Customized by you") throw new Error("custom provenance mismatch");
449 store.draft.creating = true;
450 store.draft.profileId = "researcher";
451 store.selectedPrompt = "agent.system.main.specifics.md";
452 store.projectName = "";
453 if (store.promptCustomizationPath() !== "usr/agents/researcher/prompts/agent.system.main.specifics.md") throw new Error("Global prompt customization path mismatch");
454 store.projectName = "demo";
455 if (store.promptCustomizationPath() !== "usr/projects/demo/.a0proj/agents/researcher/prompts/agent.system.main.specifics.md") throw new Error("project prompt customization path mismatch");
456 store.projectName = "";
457 store.state.model_preset.effective = "Current";
458 store.state.model_presets = [{ name: "Codex" }];
459 store.draft.modelPreset = "Codex";
460 if (store.buildPatch().model_preset?.name !== "Codex") throw new Error("Easy model preset was not saved");
461 loadHandler = () => ({ ok: true, state: { model_presets: [{ name: "Codex" }] } });
462 await store.openPresetManager();
463 loadHandler = null;
464 if (!calls.some(call => call.endpoint === "openPresetEditor" && call.payload === "Codex")) throw new Error("Easy preset editor action did not reuse Model Configuration");
465 store.draft.modelPreset = "";
466 store.state.tools.catalog = [
467 { id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
468 { id: "local:gone", name: "gone", label: "Gone", origin: "Unavailable", available: false },
469 { id: "mcp:docs:read", name: "read", label: "Docs read", origin: "MCP", available: true },
470 ];
471 store.draft.toolPolicy = { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] };
472 store.initialDraft.toolPolicy = clone(store.draft.toolPolicy);
473 if (store.standardToolCatalog.length !== 1 || store.mcpCatalog.length !== 1 || store.toolCatalog.length !== 2) throw new Error("Easy tool/MCP grouping mismatch");
474 if (store.filteredTools("tool").length !== 2 || store.filteredTools("mcp").length !== 1) throw new Error("Advanced retained catalog grouping mismatch");
475 if (store.policyItemState("tool", "local:shell") !== "default") throw new Error("initial segmented state mismatch");
476 store.setPolicyItem("tool", "local:shell", "allow");
477 if (store.policyItemState("tool", "local:shell") !== "allow" || !store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("On selection failed");
478 store.setPolicyItem("tool", "local:shell", "block");
479 if (store.policyItemState("tool", "local:shell") !== "block" || !store.draft.toolPolicy.blocked.includes("local:shell")) throw new Error("Off selection failed");
480 if (JSON.stringify(store.skillWarnings({ allowed_tools: ["shell"] })) !== JSON.stringify(["shell"])) throw new Error("live skill warning missing");
481 store.setPolicyItem("tool", "local:shell", "default");
482 if (store.draft.toolPolicy.mode !== "inherit" || store.policyItemState("tool", "local:shell") !== "default") throw new Error("segmented undo did not collapse to inherit");
483 store.setPolicyDefault("tool", "block");
484 store.setPolicyItem("tool", "local:shell", "allow");
485 store.setPolicyDefault("tool", "allow");
486 if (!store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("explicit On was lost when the default changed");
487 store.setPolicyItem("tool", "local:shell", "block");
488 store.setPolicyDefault("tool", "block");
489 if (!store.draft.toolPolicy.blocked.includes("local:shell")) throw new Error("explicit Off was lost when the default changed");
490 store.setPolicyItem("tool", "local:shell", "default");
491 store.setPolicyDefault("tool", "allow");
492 if (store.draft.toolPolicy.mode !== "inherit") throw new Error("default undo did not collapse to inherit");
493 store.setPolicyDefault("mcp", "block");
494 if (store.policyDefault("tool") !== "allow" || store.policyDefault("mcp") !== "block") throw new Error("tool and MCP defaults were not independent");
495 if (!store.isToolAllowed(store.state.tools.catalog[0]) || store.isToolAllowed(store.state.tools.catalog[2])) throw new Error("MCP default affected the wrong catalog group");
496 store.setPolicyItem("mcp", "mcp:docs:read", "allow");
497 store.setPolicyDefault("mcp", "allow");
498 if (!store.draft.toolPolicy.allowed.includes("mcp:docs:read")) throw new Error("explicit MCP On was lost when its default changed");
499 store.setPolicyItem("mcp", "mcp:docs:read", "default");
500 if (store.draft.toolPolicy.mode !== "inherit") throw new Error("MCP default undo did not collapse to inherit");
501 store.projectName = "demo";
502 store.intent = { ...store.intent, projectName: "demo" };
503 if (!store.currentChatUsesScope() || !store.isProfileActive("researcher")) throw new Error("active project profile state mismatch");
504 store.profiles = [{ id: "researcher", title: "Researcher", enabled: true }, { id: "default", title: "Default", enabled: true }];
505 if (store.activeProfile()?.id !== "researcher") throw new Error("active profile summary mismatch");
506 await store.setProfileEnabled(store.profiles[0], false);
507 if (!calls.some(item => item.payload?.action === "set_enabled" && item.payload.profile_id === "researcher") || chatsStore.selectedContext.agent_profile !== "default") throw new Error("profile availability did not reconcile the active chat");
508 if (!calls.some(item => item.endpoint === "loadAgentProfiles" && item.payload === true)) throw new Error("profile switcher did not refresh eagerly");
509 let releaseFirstToggle;
510 const firstToggleResponse = new Promise(resolve => { releaseFirstToggle = resolve; });
511 let toggleRequest = 0;
512 setEnabledHandler = () => {
513 toggleRequest += 1;
514 return toggleRequest === 1
515 ? firstToggleResponse
516 : Promise.resolve({ ok: true });
517 };
518 const rapidProfiles = [
519 { id: "agent0", title: "Agent 0", enabled: true },
520 { id: "developer", title: "Developer", enabled: true },
521 ];
522 store.profiles = rapidProfiles;
523 const firstToggle = store.setProfileEnabled(rapidProfiles[0], false);
524 while (!toggleRequest) await Promise.resolve();
525 const secondToggle = store.setProfileEnabled(rapidProfiles[1], false);
526 await secondToggle;
527 if (toggleRequest !== 1 || rapidProfiles[1].enabled !== true || !store.profileAvailabilitySaving) throw new Error("availability saves were not serialized");
528 releaseFirstToggle({ ok: true });
529 await firstToggle;
530 if (store.profiles !== rapidProfiles || rapidProfiles[0].enabled || rapidProfiles[1].enabled !== true || store.profileAvailabilitySaving) throw new Error("first availability save did not settle cleanly");
531 await store.setProfileEnabled(rapidProfiles[1], false);
532 if (toggleRequest !== 2 || rapidProfiles[1].enabled || store.profileAvailabilitySaving) throw new Error("availability gate did not reopen after save");
533 setEnabledHandler = null;
534 await store.duplicateProfile({ id: "researcher", title: "Researcher" });
535 if (!calls.some(item => item.payload?.action === "duplicate" && item.payload.profile_id === "researcher") || !store.profiles.some(profile => profile.id === "researcher-1")) throw new Error("profile duplication failed");
536 calls.length = 0;
537 confirmResult = false;
538 await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
539 if (calls.length || confirmations.at(-1)?.title !== "Reset Researcher to default?") throw new Error("restore cancellation failed");
540 confirmResult = true;
541 profilesHandler = () => ({
542 ok: true,
543 profiles: [{ id: "researcher", title: "Researcher", scope_has_overrides: false }],
544 });
545 await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
546 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");
547 if (!calls.some(item => item.endpoint === "loadAgentProfiles" && item.payload === true) || store.saving) throw new Error("reset to default did not refresh profile state");
548 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");
549 profilesHandler = null;
550 let editorReload = null;
551 const loadEditor = store.loadEditor;
552 const setMode = store.setMode;
553 store.view = "editor";
554 store.mode = "advanced";
555 store.section = "3";
556 store.loadEditor = async (...args) => { editorReload = { args }; };
557 store.setMode = (...args) => { editorReload.mode = args; };
558 await store.restoreProfile({ id: "researcher", title: "Researcher", scope_has_overrides: true, deletable: false });
559 if (editorReload?.args.join(":") !== "researcher:false" || editorReload.mode.join(":") !== "advanced:3:false") throw new Error("editor reset did not restore its surface");
560 store.loadEditor = loadEditor;
561 store.setMode = setMode;
562 store.view = "manage";
563 confirmations.length = 0;
564 confirmResult = false;
565 store.projectName = "other";
566 if (store.currentChatUsesScope() || store.isProfileActive("default")) throw new Error("foreign project profile appeared active");
567 store.projectName = "demo";
568 store.state.tools.effective_policy = { mode: "custom", default: "allow", mcp_default: "allow", allowed: [], blocked: ["local:shell"] };
569 if (store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope ignored inherited tool restriction");
570 store.setPolicyItem("tool", "local:shell", "default");
571 if (store.draft.toolPolicy.mode !== "custom" || !store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope did not customize inherited policy");
572 store.setPolicyItem("tool", "local:shell", "allow");
573 if (!store.draft.toolPolicy.allowed.includes("local:shell")) throw new Error("project scope did not pin explicit On");
574 store.setPolicyItem("tool", "local:shell", "block");
575 if (store.draft.toolPolicy.mode !== "inherit" || store.isToolAllowed(store.state.tools.catalog[0])) throw new Error("project scope did not restore inherited policy");
576 store.projectName = "";
577 store.state.tools.effective_policy = { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] };
578 store.state.skills.catalog = [
579 { name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research sources", available: true, tags: [], allowed_tools: [] },
580 { name: "Gone", path: "skills/gone/SKILL.md", origin: "Unavailable", description: "Missing skill", available: false, tags: [], allowed_tools: [] },
581 ];
582 store.draft.skillPolicy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
583 store.initialDraft.skillPolicy = clone(store.draft.skillPolicy);
584 if (store.skillCatalog.length !== 1 || store.filteredSkills().length !== 2) throw new Error("Easy/Advanced skill catalog mismatch");
585 store.setPolicyItem("skill", "Research", "allow");
586 if (!store.draft.skillPolicy.allowed.includes("Research")) throw new Error("skill On selection failed");
587 store.setPolicyItem("skill", "Research", "block");
588 if (!store.draft.skillPolicy.blocked.includes("Research")) throw new Error("skill Off selection failed");
589 store.setPolicyItem("skill", "Research", "default");
590 if (store.draft.skillPolicy.mode !== "inherit") throw new Error("skill sparse undo did not collapse");
591 store.draft.title = "Preserved Agent";
592 store.onNameInput();
593 store.instructions.value = "Preserved instructions";
594 store.draft.description = "Created description";
595 store.draft.context = "Delegate created work";
596 const createdMetadata = store.buildPatch().metadata.set;
597 if (createdMetadata.description !== "Created description" || createdMetadata.context !== "Delegate created work") throw new Error("Advanced create metadata was omitted");
598 store.mode = "advanced";
599 store.instructions.value = "";
600 const callsBeforeInvalidAdvancedCreate = calls.length;
601 if (!store.fieldIssue("instructions")) throw new Error("Advanced create accepted empty instructions");
602 if (store.fieldIssue("instructions")?.field !== "agent-editor-prompt-ace") throw new Error("Advanced validation targeted the wrong editor");
603 await store.save();
604 if (calls.length !== callsBeforeInvalidAdvancedCreate) throw new Error("Advanced create submitted empty instructions");
605 store.instructions.value = "Preserved instructions";
606 store.draft.creating = false;
607 store.mode = "easy";
608 store.instructions.initialValue = "Preserved instructions";
609 store.instructions.value = "";
610 if (!store.fieldIssue("instructions")) throw new Error("existing empty Easy instructions were accepted");
611 store.instructions.value = "Preserved instructions";
612 if (store.fieldIssue("instructions")) throw new Error("corrected Easy instructions kept a validation error");
613 store.mode = "advanced";
614 store.instructions.value = "";
615 if (store.fieldIssue("instructions")) throw new Error("Advanced empty instructions were rejected");
616 store.instructions.value = "Preserved instructions";
617 store.markPromptSet("agent.system.main.specifics.md");
618 if (store.instructions.reset || store.promptEditPending(store.instructions)) throw new Error("Easy instructions did not update its edit checkpoint");
619 store.restoreInstructions();
620 if (!store.instructions.reset || store.instructions.value !== "" || store.promptEditPending(store.instructions)) throw new Error("default instructions were not restored");
621 store.state = {
622 profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: { effective: { kind: "color", value: "#111111" } } } },
623 prompts: [
624 { filename: "agent.system.main.specifics.md", group: "2.1", group_label: "Agent instructions", effective: "", inherited: "Old instructions", source: "old-source", source_chain: ["Old"] },
625 { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Old comm", inherited: "Old comm", source: "old-source", source_chain: ["Old"], state: "Inherited", has_override: false },
626 ],
627 model_preset: { has_override: false, effective: "Default" },
628 model_presets: [],
629 tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
630 { id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
631 { id: "local:old", name: "old", label: "Old", origin: "Old scope", available: true },
632 ] },
633 skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
634 { name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research", available: true, tags: [], allowed_tools: [] },
635 { name: "Old skill", path: "skills/old/SKILL.md", origin: "Old scope", description: "Old", available: true, tags: [], allowed_tools: [] },
636 ] },
637 };
638 store.view = "editor";
639 store.intent = { ...store.intent, view: "create", projectName: "" };
640 store.makeDraft(true);
641 store.root = {
642 querySelector: selector => selector === "#agent-editor-prompt-ace" ? aceContainer : null,
643 contains: node => node === aceContainer,
644 closest: () => null,
645 };
646 store.mode = "advanced";
647 store.section = "2";
648 const draftBeforeAce = JSON.stringify(store.draft);
649 store.initPromptEditor();
650 if (aceState.mode !== "ace/mode/markdown" || aceState.wrap !== true || aceState.options?.showPrintMargin !== false || aceState.options?.useWorker !== false) throw new Error("ACE configuration mismatch");
651 if (aceState["aria-label"] !== "Prompt Markdown" || JSON.stringify(store.draft) !== draftBeforeAce) throw new Error("ACE initialization created a false edit");
652 aceState.value = "Edited in ACE";
653 aceState.change();
654 if (store.instructions.value !== "Edited in ACE" || !store.promptEditPending(store.instructions)) throw new Error("ACE change did not update the prompt draft");
655 store.selectPrompt("agent.system.main.communication.md");
656 if (aceState.value !== "Old comm" || store.instructions.value !== "Edited in ACE") throw new Error("ACE file switch lost a draft");
657 store.selectPrompt("agent.system.main.specifics.md");
658 store.destroyPromptEditor();
659 if (!aceState.destroyed || aceState.change) throw new Error("ACE instance was not destroyed cleanly");
660 store.draft.title = "Scoped Agent";
661 store.onNameInput();
662 store.draft.description = "Scoped description";
663 store.draft.context = "Use for scoped work";
664 store.instructions.value = "Authored instructions";
665 store.markPromptSet("agent.system.main.specifics.md");
666 store.draft.prompts["agent.system.main.communication.md"].value = "Authored communication";
667 store.acceptPromptEdit("agent.system.main.communication.md");
668 store.chooseAvatarColor("#ABCDEF");
669 store.setPolicyItem("tool", "local:shell", "block");
670 store.setPolicyDefault("tool", "block");
671 store.setPolicyItem("skill", "Research", "block");
672 const projectState = {
673 profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: { effective: { kind: "color", value: "#222222" } } } },
674 prompts: [
675 { filename: "agent.system.main.specifics.md", group: "2.1", group_label: "Agent instructions", effective: "", inherited: "Project instructions", source: "project-source", source_chain: ["Project"] },
676 { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Inherited comm", inherited: "Inherited comm", source: "project-source", source_chain: ["Project"], state: "Inherited", has_override: false },
677 ],
678 model_preset: { has_override: false, effective: "Default" },
679 model_presets: [],
680 tools: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", mcp_default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
681 { id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true },
682 { id: "local:new", name: "new", label: "New", origin: "Project", available: true },
683 ] },
684 skills: { policy: { mode: "inherit" }, effective_policy: { mode: "inherit", default: "allow", allowed: [], blocked: [] }, has_override: false, catalog: [
685 { name: "Research", path: "skills/research/SKILL.md", origin: "Agent Zero", description: "Research", available: true, tags: [], allowed_tools: [] },
686 { name: "New skill", path: "skills/new/SKILL.md", origin: "Project", description: "New", available: true, tags: [], allowed_tools: [] },
687 ] },
688 };
689 loadHandler = () => ({ ok: true, state: projectState });
690 store.mode = "advanced";
691 store.section = "6";
692 store.projectName = "demo";
693 store.intent = { ...store.intent, projectName: "" };
694 calls.length = 0;
695 await store.onScopeChanged();
696 if (store.state !== projectState || store.draft.title !== "Scoped Agent" || store.draft.profileId !== "scoped-agent") throw new Error("create scope rebase lost identity");
697 if (store.draft.description !== "Scoped description" || store.draft.context !== "Use for scoped work") throw new Error("create scope rebase lost authored metadata");
698 if (store.instructions.value !== "Authored instructions" || store.instructions.source !== "project-source") throw new Error("create scope rebase kept stale prompt provenance");
699 if (store.draft.avatar?.value !== "#ABCDEF" || store.isToolAllowed(projectState.tools.catalog[0]) || store.isToolAllowed(projectState.tools.catalog[1]) || store.draft.toolPolicy.default !== "block") throw new Error("create scope rebase lost avatar or tool policy");
700 if (store.isSkillAllowed(projectState.skills.catalog[0]) || !store.isSkillAllowed(projectState.skills.catalog[1])) throw new Error("create scope rebase lost explicit skill decision");
701 if (store.draft.prompts["agent.system.main.communication.md"].value !== "Authored communication" || store.draft.prompts["agent.system.main.communication.md"].source !== "project-source" || store.promptEditPending(store.draft.prompts["agent.system.main.communication.md"])) throw new Error("create scope rebase lost an accepted prompt edit or kept stale provenance");
702 if (calls.at(-1)?.payload?.action !== "plan" || calls.at(-1)?.payload?.project_name !== "demo" || store.planStatus !== "ready" || !store.plan.written[0].startsWith("usr/projects/demo/.a0proj/agents/scoped-agent/")) throw new Error("Review plan was not recomputed after scope change");
703 loadHandler = null;
704 store.projectName = "";
705 store.intent = { ...store.intent, projectName: "" };
706 const communication = store.draft.prompts["agent.system.main.communication.md"];
707 communication.value = communication.initialValue;
708 communication.reset = false;
709 store.acceptPromptEdit(communication.filename);
710 if (store.filteredPromptFiles("2.4")[0] !== communication) throw new Error("grouped prompt filter mismatch");
711 if (store.promptEditPending(communication) || store.promptDisplayState(communication) !== "Default") throw new Error("default prompt checkpoint mismatch");
712 communication.value += "\nNew rule";
713 store.onPromptInput(communication.filename);
714 if (!store.promptEditPending(communication)) throw new Error("prompt edit actions did not appear");
715 if (!store.buildPatch().prompts.set[communication.filename].endsWith("New rule")) throw new Error("pending prompt edit missing from sparse patch");
716 store.discardPromptEdit(communication.filename);
717 if (store.promptEditPending(communication) || communication.value !== "Inherited comm") throw new Error("prompt edit was not discarded");
718 if (store.buildPatch().prompts.set[communication.filename]) throw new Error("discarded prompt edit remained in sparse patch");
719 communication.value += "\nNew rule";
720 store.onPromptInput(communication.filename);
721 store.acceptPromptEdit(communication.filename);
722 if (store.promptEditPending(communication)) throw new Error("prompt edit was not accepted");
723 if (store.promptDisplayState(communication) !== "Customized by you") throw new Error("customized state missing");
724 store.resetPrompt(communication.filename);
725 if (store.promptEditPending(communication) || store.promptDisplayState(communication) !== "Will use the default") throw new Error("reset state mismatch");
726 const draftBeforeModes = JSON.stringify(store.draft);
727 store.setMode("advanced", "2");
728 store.setMode("easy");
729 if (store.section !== "2" || JSON.stringify(store.draft) !== draftBeforeModes) throw new Error("mode switch lost draft");
730 store.setMode("advanced", "6");
731 await Promise.resolve();
732 await Promise.resolve();
733 if (store.planStatus !== "ready" || calls.at(-1).payload.action !== "plan") throw new Error("review plan was not computed on entry");
734 calls.length = 0;
735 store.intent = { contextId: "source-chat" };
736 await store.openFreshChat("researcher", true);
737 const endpoints = calls.map((item) => item.endpoint);
738 const expected = ["/chat_create", "/projects", "/agent_profile_set", "/plugins/_model_config/model_override", "selectChat", "event"];
739 if (JSON.stringify(endpoints) !== JSON.stringify(expected)) throw new Error(JSON.stringify(calls));
740 if (calls[1].payload.action !== "deactivate") throw new Error("global test chat kept a project");
741 if (calls[2].payload.agent_profile !== "researcher") throw new Error("profile not selected");
742 if (calls[3].payload.action !== "clear") throw new Error("chat preset override not cleared");
743 if (store.readyNoteContext !== "fresh-chat") throw new Error("ready note missing");
744 if (chatsStore.selectedContext.agent_profile !== "researcher" || chatsStore.selectedContext.agent_profile_label !== "Researcher") throw new Error("fresh chat showed a stale profile");
745 calls.length = 0;
746 store.projectName = "demo";
747 await store.openFreshChat("researcher", false);
748 if (calls[1].endpoint !== "/projects" || calls[1].payload.action !== "activate" || calls[1].payload.name !== "demo") throw new Error("project test chat did not activate selected scope");
749 store.draft.title = `${store.draft.title} dirty`;
750 calls.length = 0;
751 if (await store.planRemoval(true)) throw new Error("removal plan accepted unsaved edits");
752 if (calls.length || !store.error.includes("Save or discard")) throw new Error("dirty removal plan was not blocked locally");
753 store.initialDraft = clone(store.draft);
754 store.error = "";
755 await store.planRemoval(true);
756 if (!store.pendingMutation?.destructive || store.section !== "6" || store.planStatus !== "ready") throw new Error("removal plan was replaced");
757 if (calls.at(-1).payload.action !== "plan_remove_changes") throw new Error("removal plan request missing");
758 if (calls.at(-1).payload.project_name !== "demo") throw new Error("removal request lost selected scope");
759 const callsBeforePendingSave = calls.length;
760 if (await store.save() || calls.length !== callsBeforePendingSave) throw new Error("ordinary save ran over a pending removal plan");
761 store.draft.title += " changed after planning";
762 if (await store.applyPendingMutation() !== false || confirmations.length || calls.length !== callsBeforePendingSave || !store.error.includes("before applying")) throw new Error("removal plan discarded edits made after planning");
763 store.draft.title = store.initialDraft.title;
764 store.error = "";
765 store.plan = { written: ["usr/agents/researcher/agent.yaml"], deleted: ["usr/agents/researcher/prompts/old.md"], warnings: [] };
766 confirmResult = true;
767 loadHandler = () => ({ ok: true, state: store.state });
768 await store.applyPendingMutation();
769 if (confirmations.length !== 1 || confirmations[0].type !== "danger") throw new Error("danger confirmation missing");
770 if (!confirmations[0].message.includes("agent.yaml") || !confirmations[0].message.includes("old.md")) throw new Error("planned paths missing from confirmation");
771 if (confirmations[0].title !== "Delete all customizations for this profile?") throw new Error("cleanup confirmation title mismatch");
772 const removalCall = calls.find(item => item.payload?.action === "remove_changes");
773 if (!removalCall || removalCall.payload.confirm !== true || removalCall.payload.destructive !== true) throw new Error("destructive removal omitted explicit confirmation");
774 loadHandler = null;
775 confirmResult = false;
776 confirmations.length = 0;
777 calls.length = 0;
778 store.projectName = "";
779 await store.deleteProfile("custom-agent");
780 if (calls.length) throw new Error("cancelled deletion made an API request");
781 if (confirmations.length !== 1 || confirmations[0].title !== "Delete custom-agent?") throw new Error("delete confirmation mismatch");
782 if (confirmations[0].message !== "<p>This agent profile will be permanently deleted from Global and cannot be recovered.</p>") throw new Error("delete confirmation is not concise");
783 confirmResult = true;
784 store.view = "editor";
785 store.mode = "advanced";
786 store.draft = { title: "dirty", avatar: null, avatarToken: "", metadataResets: [] };
787 store.initialDraft = { title: "clean", avatar: null, avatarToken: "", metadataResets: [] };
788 store.promptEditBaselines = { stale: { value: "stale", reset: false } };
789 await store.deleteProfile("custom-agent");
790 if (store.view !== "manage" || store.mode !== "easy" || store.draft !== null || store.initialDraft !== null || Object.keys(store.promptEditBaselines).length) throw new Error("delete did not enter a clean Manage state");
791 store.view = "editor";
792 store.mode = "advanced";
793 store.draft = { title: "dirty" };
794 store.initialDraft = { title: "clean" };
795 store.promptEditBaselines = { stale: { value: "stale", reset: false } };
796 store.error = "stale";
797 store.showManager();
798 if (store.view !== "manage" || store.mode !== "easy" || store.draft !== null || store.initialDraft !== null || store.error || Object.keys(store.promptEditBaselines).length) throw new Error("Back did not discard editor state before Manage");
799 """
800 module_source = harness + "\n" + source + "\n" + checks
801 module_url = "data:text/javascript;base64," + base64.b64encode(
802 module_source.encode("utf-8")
803 ).decode("ascii")
804 subprocess.run(
805 ["node", "--input-type=module", "-e", f"await import('{module_url}')"],
806 check=True,
807 text=True,
808 )
809
810
811 @pytest.mark.skipif(not shutil.which("node"), reason="node is required")
812 def test_agent_profile_loads_dedupe_same_context_and_ignore_stale_context() -> None:
813 source = SWITCHER_MIXIN.read_text(encoding="utf-8")
814 source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
815 harness = r"""
816 const pending = [];
817 const callJsonApi = async () => await new Promise(resolve => pending.push(resolve));
818 const fetchApi = async () => ({ ok: true, json: async () => ({}) });
819 let selectedContextId = "ctx";
820 globalThis.window = { Alpine: { store: () => ({ selected: selectedContextId }) } };
821 """
822 checks = r"""
823 const store = { ...switcherState, ...switcherMethods };
824 const older = store.loadAgentProfiles(true);
825 const newer = store.loadAgentProfiles(true);
826 if (pending.length !== 1 || !store.agentProfilesLoading) throw new Error("same-context profile loads were not deduplicated");
827 pending[0]({ profiles: [
828 { id: "default", title: "Default", enabled: true },
829 { id: "new", title: "New", enabled: true },
830 ] });
831 await Promise.all([older, newer]);
832 if (store.agentProfiles[0]?.key !== "new" || store.agentProfilesLoading || !store.agentProfilesLoaded) throw new Error("newest profile load did not settle");
833 if (store.agentProfiles.length !== 1 || store.getAgentProfileList("default", "Default").some(profile => profile.key === "default")) throw new Error("Default profile remained selectable in the chat popover");
834 selectedContextId = "older-context";
835 const stale = store.loadAgentProfiles(true);
836 selectedContextId = "newer-context";
837 const fresh = store.loadAgentProfiles(true);
838 if (pending.length !== 3) throw new Error("different-context profile loads were incorrectly deduplicated");
839 pending[2]({ profiles: [{ id: "fresh", title: "Fresh", enabled: true }] });
840 await fresh;
841 pending[1]({ profiles: [{ id: "stale", title: "Stale", enabled: true }] });
842 await stale;
843 if (store.agentProfiles[0]?.key !== "fresh" || store.agentProfilesLoading) throw new Error("stale profile load replaced newer state");
844 const requestCount = pending.length;
845 await store.loadAgentProfiles();
846 if (pending.length !== requestCount) throw new Error("cached profile catalog unexpectedly reloaded");
847 """
848 module_source = harness + "\n" + source + "\n" + checks
849 module_url = "data:text/javascript;base64," + base64.b64encode(
850 module_source.encode("utf-8")
851 ).decode("ascii")
852 subprocess.run(
853 ["node", "--input-type=module", "-e", f"await import('{module_url}')"],
854 check=True,
855 text=True,
856 )
857
858
859 def test_profile_slash_effects_reuse_the_agent_editor_entry_points() -> None:
860 slash_store = (
861 ROOT / "plugins" / "_commands" / "webui" / "commands-slash-store.js"
862 ).read_text(encoding="utf-8")
863
864 assert 'type === "open_agent_editor"' in slash_store
865 assert "globalThis.openAgentEditor?." in slash_store
866 assert 'type === "test_agent_profile"' in slash_store
867 assert "globalThis.testAgentProfile?." in slash_store
868 assert "String(effect.project_name || \"\")" in slash_store