// #1873 A2: Settings > Agents, the root and project edit forms against their grids, in a real // browser on the real routes and writers (a temporary config directory with one organization). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs'; import { loadRootAgents } from '../../organization/src/index.mjs'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { agentLevelsWriteRoutes } from '../src/agent-levels-write.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; const SECRET = 'sk-test-NOT-A-SECRET-1234'; const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); // orgSend gives the selected organization a send transport, so the role-instance Settings forms // are writable too and their handlers are live beside the level forms. async function open(t, { send = true, orgSend = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'ale-')); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); const doc = () => JSON.parse(readFileSync(file, 'utf8')); const instances = { business: 'demo', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: { chief: { definition: 'chief-of-staff', vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [] } } }; const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }], members: async () => [{ id: 'demo', doc: doc() }], resolve: async id => id !== 'demo' ? null : { busHealthy: true, ...(orgSend ? { send: async () => ({}) } : {}), reads: { business: async () => ({ business: { id: 'demo', human: 'jason', arbiters: ['chief'], projects: ['stack'], vars: {}, instances: [{ instance: 'chief', vars: {} }] } }), settings: async () => ({ business: 'demo', release: 'fixture', notifier: {}, launch: { limits: {} } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { instances: async () => instances, messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, chat: { projects: async () => [{ id: 'stack', name: 'Stack' }], threads: async () => [] }, }, }; const agentLevels = { loadRoot: () => loadRootAgents(root, { rolesDir }), rolesDir, ctx: { configDir: root, dataRoot: join(root, 'data'), instance: 'test' } }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, send: send ? async () => ({}) : null, log: () => {}, routes: [agentLevelsWriteRoutes({ configDir: root, rolesDir })] }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(1440, 900); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/demo/settings/agents`); return { b, wait: waitFor(b), file, root }; } const fill = (b, form, values) => b.evaluate(`(() => { const f = document.getElementById(${JSON.stringify(form)}); for (const [k, v] of Object.entries(${JSON.stringify(values)})) { f.elements[k].value = v; } f.requestSubmit(); })()`); // Typing: each value set, then input and change fired, as a person's edit fires them. const type = (b, form, values) => b.evaluate(`(() => { const f = document.getElementById(${JSON.stringify(form)}); for (const [k, v] of Object.entries(${JSON.stringify(values)})) { const el = f.elements[k]; el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()`); const values = (b, form) => b.evaluate(`Object.fromEntries(new FormData(document.getElementById(${JSON.stringify(form)})))`); const receipt = which => `(document.getElementById("agent-levels-${which}-receipt")?.textContent || "")`; test('the root form adds a root agent and the grid repaints from a fresh read; a refusal names the key, keeps the draft and never shows the value', async t => { const { b, wait, root } = await open(t); await wait('document.getElementById("agent-levels-root-form")'); assert.match(await b.evaluate('document.getElementById("agent-levels-root-grid").textContent'), /does not exist yet/); const before = await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'); await fill(b, 'agent-levels-root-form', { name: 'researcher', definition: 'research', model: 'claude-opus-5-5', vars: 'limits.network=none', excludeOrgs: 'home' }); await wait('document.querySelector("[data-root-agent=\\"researcher\\"]")'); await wait(`${receipt('root')}.startsWith("Saved researcher at ")`); const row = await b.evaluate('document.querySelector("[data-root-agent=\\"researcher\\"]").innerText'); assert.match(row, /research/); assert.match(row, /claude-opus-5-5/); assert.match(row, /limits\.network/); assert.match(row, /home/); assert.doesNotMatch(row, /\bnone\b/, 'only the model value is shown'); assert.notEqual(await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'), before, 'the form carries the new revision'); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), '', 'a saved form is cleared'); // A value the root file can't take: the key is named, the value is not, the draft stays. await fill(b, 'agent-levels-root-form', { name: 'researcher', definition: '', model: SECRET.repeat(10), vars: '', excludeOrgs: '' }); await wait(`${receipt('root')}.startsWith("Refused (agents-invalid)")`); const page = await b.evaluate('document.getElementById("agent-levels-edit").innerText'); assert.match(page, /model/); assert.ok(!page.includes(SECRET), 'the value never shows outside the input'); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.model.value'), SECRET.repeat(10)); assert.doesNotMatch(readFileSync(join(root, 'agents.json'), 'utf8'), /NOT-A-SECRET/); // Remove asks once more, then the row goes. await b.evaluate('document.querySelector("[data-level-remove=root][data-level-name=researcher]").click()'); await wait('document.querySelector("[data-level-remove=root][data-level-name=researcher]").textContent === "Confirm remove"'); await b.evaluate('document.querySelector("[data-level-remove=root][data-level-name=researcher]").click()'); await wait(`!document.querySelector("[data-root-agent=\\"researcher\\"]") && ${receipt('root')}.startsWith("Removed researcher")`); }); test('the project form defines, narrows and excludes, each row repainting with its form; a widening narrow is refused without the value', async t => { const { b, wait, file } = await open(t); await wait('document.getElementById("agent-levels-project-form")'); assert.equal(await b.evaluate('document.getElementById("agent-levels-org").value'), 'demo'); assert.equal(await b.evaluate('document.getElementById("agent-levels-project").value'), 'stack'); assert.match(await b.evaluate('document.getElementById("agent-levels-project-grid").textContent'), /No project entries/); await fill(b, 'agent-levels-project-form', { name: 'checker', form: 'define', definition: 'fact-checker', vars: 'model=claude-sonnet-5-5' }); await wait('document.querySelector("[data-project-agent=checker][data-form=define]")'); await wait(`${receipt('project')}.startsWith("Saved checker in stack at ")`); await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: 'limits.network=none' }); await wait('document.querySelector("[data-project-agent=coder][data-form=narrow]")'); await fill(b, 'agent-levels-project-form', { name: 'reviewer', form: 'exclude', definition: '', vars: '' }); await wait('document.querySelector("[data-project-agent=reviewer][data-form=exclude]")'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-project-agent]")].map(r => r.dataset.projectAgent + ":" + r.dataset.form)'), ['checker:define', 'coder:narrow', 'reviewer:exclude']); assert.deepEqual(Object.keys(JSON.parse(readFileSync(file, 'utf8')).projects.stack.agents).sort(), ['checker', 'coder', 'reviewer']); const before = readFileSync(file, 'utf8'); await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: `model=${SECRET}` }); await wait(`${receipt('project')}.startsWith("Refused (project-agents-invalid)")`); assert.match(await b.evaluate(receipt('project')), /can only narrow/); assert.equal(await b.evaluate('document.getElementById("agent-levels-project-form").elements.vars.value'), `model=${SECRET}`, 'a refused form keeps the draft'); assert.ok(!(await b.evaluate('document.getElementById("agent-levels-edit").innerText')).includes(SECRET)); assert.equal(readFileSync(file, 'utf8'), before); // A client-side refusal names the line, never its text. await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: SECRET }); await wait(`${receipt('project')} === "Variables line 1 needs KEY=VALUE."`); await b.evaluate('document.querySelector("[data-level-remove=project][data-level-name=checker]").click()'); await b.evaluate('document.querySelector("[data-level-remove=project][data-level-name=checker]").click()'); await wait(`!document.querySelector("[data-project-agent=checker]") && ${receipt('project')}.startsWith("Removed checker from stack")`); }); test('without --send both levels read but offer no form', async t => { const { b, wait } = await open(t, { send: false }); await wait('document.getElementById("agent-levels-edit")'); assert.match(await b.evaluate('document.getElementById("agent-levels-edit").innerText'), /started without writes/); assert.equal(await b.evaluate('!!document.getElementById("agent-levels-root-form") || !!document.getElementById("agent-levels-project-form")'), false); }); test('both drafts survive a refresh render and the other form\'s save; a draft keeps its revision; the role-instance handlers leave the level forms alone', async t => { const { b, wait, root } = await open(t, { orgSend: true }); await wait('document.getElementById("agent-levels-root-form") && document.getElementById("agent-levels-project-form")'); assert.equal(await b.evaluate('!!document.getElementById("instance-add-form")'), true, 'the role-instance forms are writable in this fixture'); assert.equal(await b.evaluate('document.querySelectorAll("#agent-levels-edit .instance-form").length'), 0); assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#agent-levels-root-form .filters")).display'), 'grid', 'the level forms keep the Settings form layout'); await type(b, 'agent-levels-root-form', { name: 'drafted', vars: 'limits.network=none' }); await type(b, 'agent-levels-project-form', { name: 'helper', form: 'narrow', vars: 'limits.tools=read' }); assert.equal(await b.evaluate('!!document.querySelector("#settings-nav a[href$=\'settings/agents\'] .settings-dot")'), true, 'the nav marks unsaved changes'); // A full Settings render, as the ten-second poll does: the edit box is a new node, the drafts are in it. await b.evaluate('document.getElementById("agent-levels-edit").dataset.old = "1"; document.getElementById("refresh").click()'); await wait('document.getElementById("agent-levels-edit") && !document.getElementById("agent-levels-edit").dataset.old'); assert.deepEqual(await values(b, 'agent-levels-root-form'), { name: 'drafted', definition: '', model: '', vars: 'limits.network=none', unset: '', excludeOrgs: '' }); assert.deepEqual(await values(b, 'agent-levels-project-form'), { name: 'helper', form: 'narrow', definition: '', vars: 'limits.tools=read' }); // The root form saves; the project draft stays as typed and the root draft clears. await type(b, 'agent-levels-root-form', { name: 'researcher', definition: 'research', vars: '' }); await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()'); await wait(`${receipt('root')}.startsWith("Saved researcher at ")`); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), ''); assert.deepEqual(await values(b, 'agent-levels-project-form'), { name: 'helper', form: 'narrow', definition: '', vars: 'limits.tools=read' }); // A locally refused submit keeps its draft; the role-instance handler takes no part. await type(b, 'agent-levels-root-form', { name: 'second', vars: 'malformed' }); await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()'); await wait(`${receipt('root')} === "Variables line 1 needs KEY=VALUE."`); await new Promise(r => setTimeout(r, 300)); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), 'second'); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.vars.value'), 'malformed'); assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .settings-actions.has-draft")'), false, 'the instance form took no draft'); // The file changes under a draft: the draft's save sends the revision it began at and is refused, // then a second, deliberate save goes through against the file as now read. await type(b, 'agent-levels-root-form', { name: 'later', definition: 'research', vars: '' }); const began = await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'); writeFileSync(join(root, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: {} }) + '\n', { mode: 0o600 }); await b.evaluate('document.getElementById("agent-levels-edit").dataset.old = "1"; document.getElementById("refresh").click()'); await wait('document.getElementById("agent-levels-edit") && !document.getElementById("agent-levels-edit").dataset.old'); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'), began, 'the draft keeps the revision it began at'); await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()'); await wait(`${receipt('root')}.startsWith("Refused: the file changed")`); assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), 'later'); await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()'); await wait(`${receipt('root')}.startsWith("Saved later at ")`); assert.deepEqual(Object.keys(JSON.parse(readFileSync(join(root, 'agents.json'), 'utf8')).agents), ['later']); });