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