From 5373b110acb8cdf7b21bd137a4bf6acb1eaf9a7c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 03:13:54 -0600 Subject: [PATCH 1/3] feat(webui): one Agents page for the whole system, with organization and state filters, Fire sent to the row's organization (#1873, lane sp5) #/agents lists every organization's agents in one table: name, role, state, what each works on, organization and project chips. Org and state filters live in the hash query. A stopped organization is named at the top. A row opens the agent in its organization; Fire is drawn only where that organization's own config lets this Console decide, and posts to that organization's hire route. #/orgs//agents leads with the same table scoped to one organization, above the levels grid and held roles. A hired agent reads as Hired (or Hired from pack X) with its org:hire id as secondary text. The sidebar and dashboard Agents links open the system page. --- packages/webui/src/public/bus.js | 72 ++++-- packages/webui/src/public/display.js | 5 +- packages/webui/src/public/shell.css | 7 + packages/webui/src/public/workspace.js | 129 ++++++++++- packages/webui/tests/shell.test.mjs | 4 +- .../webui/tests/single-pane-browser.test.mjs | 6 +- .../tests/system-agents-browser.test.mjs | 210 ++++++++++++++++++ .../tests/unified-workspace-browser.test.mjs | 3 +- 8 files changed, 401 insertions(+), 35 deletions(-) create mode 100644 packages/webui/tests/system-agents-browser.test.mjs diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index ba1796e11..502cd003b 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -488,46 +488,59 @@ const FIRE_REASON='Fired from the Agents page.'; let fireAsk=null,fireBusy=false,fireReceipt=null; const fireKey=(org,name)=>`${org}\u0000${name}`; - function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null; + // #1873 SP5: the system Agents page lists every organization's agents, so its controls carry their + // organization (data-agent-fire-org) and post to that organization's own bus, never to the one + // opened last. Its receipt box is `data-agents-receipt="*"` and shows a receipt from any of them. + // The agent cards keep the old shape: no attribute, the opened organization, canDecide. + function fireNote(scope=hireOrg()){const r=fireReceipt&&(scope==='*'?fireReceipt.system:fireReceipt.org===scope)?fireReceipt:null; return r?`

${txt(r.text)}${r.code?` (${txt(r.code)})`:''}

`:'';} - function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();} - async function fireAgent(name){ - const org=hireOrg(); - if(fireBusy||!org||out()||!canDecide)return; + function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());} + const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`; + async function fireAgent(name,named=null){ + const org=named??hireOrg(); + // A named organization was checked against its own config when the control was drawn (decidesIn). + if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)return; fireBusy=true; for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true; const epoch=orgEpoch; - const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}}); + const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}}); if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;} fireBusy=false;fireAsk=null; if(res?.type==='opaqueredirect'){signOut();return;} + const where=named?` in ${org}`:''; if(res?.ok && json?.op==='resolved'){ for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove(); for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove(); - fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`}; + for(const r of document.querySelectorAll(`[data-ws-agent="${CSS.escape(`${org}:${name}`)}"]`))r.remove(); + fireReceipt={org,system:!!named,text:`Fired ${name}${where}. Recorded decision ${json.id}.`}; paintFireNote();announce(fireReceipt.text); document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}})); } else { const r=decideRefusal(res,json,'outcome-unknown'); - fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code}; + fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code}; paintFireNote(); - for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name); + for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named); } } - const fireButton=name=>``; - const fireConfirm=name=>`Fire ${txt(name)}? `; - // What the agent card shows for one agent: nothing without the decide right or outside the page's own organization. - function fireControl(org,name){ - if(!canDecide||isTester||org!==hireOrg())return ''; - return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name); + const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:''; + const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`); + const fireButton=(name,org=null)=>``; + const fireConfirm=(name,org=null)=>`Fire ${txt(name)}? `; + // What the agent card shows for one agent: nothing without the decide right or outside the page's own + // organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn); + // the control is then drawn for that organization, whichever one was opened last. + function fireControl(org,name,allowed){ + const named=typeof allowed==='boolean'; + if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return ''; + return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null):fireButton(name,named?org:null); } document.addEventListener('click',e=>{ const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]'); if(!t)return; - const org=hireOrg(); - if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();} - else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');} - else void fireAgent(t.dataset.agentFireYes); + const named=t.dataset.agentFireOrg??null,org=named??hireOrg(); + if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();} + else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named);announce('Cancelled. Nothing was fired.');} + else void fireAgent(t.dataset.agentFireYes,named); }); window.MosaicDecide=Object.freeze({fireControl,fireNote}); document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);}); @@ -876,12 +889,15 @@ return `
${title}
`; } } - async function agentsView(get) { + // SP5: an organization's scoped page leads with its rows of the system Agents table (which owns the + // heading), then the levels grid under its own h2, then held roles and threads. + const scopedTop = async (q, org, note) => `${await window.MosaicWorkspace.agentsPage(q, { scope: org })}${await window.MosaicWorkspace.agentGrid(note, undefined, { under: true })}`; + async function agentsView(get, q = new URLSearchParams(), org = null) { let claims; try { claims = await get('agents'); } catch (err) { if (!window.MosaicOrg || err.code === 'signed-out') throw err; if (refusal(err)) forget(); - return `${await window.MosaicWorkspace.agentGrid()}`; + return `${org ? await scopedTop(q, org) : await window.MosaicWorkspace.agentGrid()}`; } let instances = null, threads = []; if (!isTester) { @@ -898,7 +914,7 @@ const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running); const rows = claims.map(c => `${txt(c.role)}${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}${attention(c)}${txt(holders.get(c.holder_run) || c.role)}run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}${txt(c.harness)}${c.address ? `${txt(c.address)}` : ''}${when(c.at)}`).join(''); // Organization mode (sample 08): the levels grid carries the page heading; held roles follow. - const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get)); + const top = !window.MosaicOrg ? head('Agents', readNote(get)) : org ? await scopedTop(q, org, readNote(get)) : await window.MosaicWorkspace.agentGrid(readNote(get)); return `${top}

Held roles ${claims.length}

The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.

${claims.length ? `
${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => ``).join('')}${rows}
${h}
` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}
@@ -1677,6 +1693,13 @@ if (a === 'messages' && n === 1) return messagesView(get, q); if (a === 'tasks' && n === 1) return tasksView(get); if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`); + // SP5: #/agents is every organization's agents; #/orgs//agents is that table for one, above its levels grid and held roles. + if (a === 'agents' && n === 1 && window.MosaicOrg) { + const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1]; + let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); } + if (!org) return configRead.then(() => window.MosaicWorkspace.agentsPage(q, { scope: null, fireIn: decidesIn })); + return agentsView(get, q, org); + } if (a === 'agents' && n === 1) return agentsView(get); if (a === 'queue' && n === 1) return queueView(get, q); if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`); @@ -1692,7 +1715,7 @@ const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true'; // A file open in the editor is never refreshed from under its draft; Reload is the person's to press. // The cross-organization pages (U3) poll at the workspace read's pollMs. - const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox'); + const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents'); function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); } // Keep focus, open
and the copy status across a refresh of the same page. // In the send form, the focused field and its caret too (its values come from `draft`). @@ -2068,7 +2091,8 @@ // E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a // fresh read of the page; editors in progress are kept by holdLive. // Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll. - document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); }); + // SP5: the system Agents page shows every organization, so a change in any of them redraws it. + document.addEventListener('mosaic-org-changed', e => { if (!window.MosaicOrg) return; const path = routeOf().split('?')[0]; if ((path === '/agents' && window.MosaicOrg.system()) || (e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(path))) render(false); }); // #1873 SP1: another organization opened in place (display.js, before this file's hashchange // render). Nothing read, drafted or confirmed for the old one carries over; the new one's config // is read before the next page draws. A write already sent finishes against the old organization, diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 87d6125fb..40eeebc3c 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -22,8 +22,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // an organization page without the prefix gets the current organization's. #1873 SP2: an // organization's own bare address (#/orgs/, no trailing slash) is that organization's page; // #/orgs// stays the old dashboard address. SP3: `#/inbox` is a system route too (every - // organization's items); `#/orgs//inbox` is that list, filtered. - const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && bare === '/inbox'); }; + // organization's items); `#/orgs//inbox` is that list, filtered. SP5: `#/agents` is every + // organization's agents; `#/orgs//agents` is that table for one organization. + const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents')); }; const normalize = (hash, seg = current) => { const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1]; if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`; diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 2f36f5eaa..80a498fe7 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -692,6 +692,13 @@ html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center; html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px} html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px} html[data-organizations] .ws-ichips .via{display:inline} +/* #1873 SP5: the system Agents table. Open and Fire sit side by side at one size; a confirm wraps in its cell. */ +html[data-organizations] .ws-agt .ws-arole{font-size:12px;color:var(--muted)} +html[data-organizations] .ws-agt td.ws-act{white-space:nowrap;text-align:right;width:1%} +html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px} +html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)} +html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal} +html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere} html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))} html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none} html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index aa2cc5c3d..3d82f4265 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -363,6 +363,17 @@ return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null }; }; const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' }; + // SP5: what an agent is, in words. A hired agent's definition is `org:hire-`. No read names + // the template it was hired from, so it reads as hired, and from which pack when a pack hired it; + // the definition id stays as secondary text. + const isHired = item => typeof item?.hireDecision === 'string' || /^org:hire-/.test(String(item?.definition ?? '')); + const packOf = item => (typeof item?.pack?.label === 'string' && item.pack.label ? item.pack.label : typeof item?.pack?.id === 'string' ? item.pack.id : ''); + const ROLE_WORD = { 'chief-of-staff': 'Chief of staff', assistant: 'Assistant' }; + const roleWords = item => { + const d = typeof item?.definition === 'string' ? item.definition : ''; + if (isHired(item)) return packOf(item) ? `Hired from pack ${packOf(item)}` : 'Hired'; + return ROLE_WORD[d] || d.replace(/^org:/, '') || 'not reported'; + }; const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; }; let agentsPick = null; // the project chosen in "Available in a project", kept across repaints // Repaints that section alone when the choice changes, one renderer per organization: a side-by-side @@ -392,7 +403,8 @@ if (!set && inst === null) return `

${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}

`; if (!rows.length) return '

No agents configured

'; const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`); - const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · '); + const hired = a => a.level === 'org' && isHired({ ...inst?.[a.name], definition: a.definition }); + const sub = a => [hired(a) ? 'hired' : a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', hired(a) ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · '); const status = a => { if (a.assistant) return dot('s-unknown', '?', 'status not reported', true); if (a.level !== 'org') { @@ -425,9 +437,10 @@ return `
${body}
AgentLevelDefined inMemoryStatusModel
${note}`; } - async function agentGrid(note = '', selected = window.MosaicOrg?.id) { + // SP5: `opts.under` draws it below the scoped Agents table (#/orgs//agents), which owns the page heading. + async function agentGrid(note = '', selected = window.MosaicOrg?.id, opts = {}) { const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]); - const head = `

Agents

Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}

`; + const head = `
${opts.under ? '

Agents by level

' : '

Agents

'}

Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}

`; if (!Array.isArray(list)) return `
${head}

Organizations unavailable.

`; const valid = list.filter(org => !org.invalid); const admitted = valid.some(org => org.id === selected); @@ -674,7 +687,7 @@
${secH('Needs you', '', 'Inbox')} ${needsList}${missLine(o, ['inbox'], 'needs')} - ${secH('Active agents', 'sessions now', `Agents`)} + ${secH('Active agents', 'sessions now', 'Agents')} ${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
@@ -879,6 +892,108 @@ `; } + // ---- agents across organizations (#1873 SP5, prototype shot 14) ---- + // One table of every organization's agents: the organization's own instances and the root and + // project copies its bus booted with, from each organization's instances read; what each one works + // on from the workspace read's role sessions. Filters by organization and state live in the hash. + // `opts.scope` names one organization (#/orgs//agents): the same table, that organization only. + // `opts.fireIn(org)` says from that organization's own config whether this Console decides there; + // Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js). + const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; + const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' }; + async function agentsPage(q, opts = {}) { + const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null; + const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id)); + const want = String(q?.get?.('state') || ''); + const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null; + let body; + try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); } + catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); } + const every = orgsOf(body); + const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every; + const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok'); + // Each answering organization's instances, and (system page only) whether this Console decides there. + const reads = await Promise.all(askable.map(r => Promise.all([ + data.instances(r.org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })), + !scope && typeof opts.fireIn === 'function' ? opts.fireIn(r.org).then(v => v === true, () => false) : false, + ]).then(([v, fire]) => ({ id: r.org, ...v, fire })))); + const sessions = arr(body.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string'); + const projects = arr(body.projects).filter(x => typeof x?.org === 'string'); + const projectRef = (id, p) => { const hit = projects.find(x => x.org === id && (x.id === p || x.key === p)); return hit ? { id: hit.id, name: hit.name || hit.key || hit.id } : { id: p, name: p }; }; + const all = []; + for (const r of reads) { + if (!r.inst) continue; + const mine = sessions.filter(x => x.org === r.id); + const agents = mine.map(x => ({ role: x.role, holder_run: x.run, state: x.state })); + const session = name => mine.find(x => x.role === name) || null; + for (const [name, item] of Object.entries(r.inst)) { + const d = activity(name, item, agents); + // Never Fire the chief, the assistant or an instance the bus would refuse to end. + const keep = item?.definition === 'chief-of-staff' || name === 'chief' || item?.definition === 'assistant' || item?.endProtected === true; + all.push({ org: r.id, key: name, name, level: 'org', role: roleWords(item), id: isHired(item) ? item.definition : '', dot: d, s: session(name), fire: r.fire && !keep }); + } + for (const [key, c] of Object.entries(r.copies || {})) { + if (!c || typeof c.name !== 'string') continue; + const d = copyDot(key, c, agents); + all.push({ org: r.id, key, name: c.name, level: c.level === 'project' ? 'project' : 'root', role: roleWords(c), id: copyBlocked(c) ? copyWhy(c) : '', dot: d, s: session(key), project: c.level === 'project' && c.project ? projectRef(r.id, c.project) : null, fire: false }); + } + } + for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running'; + const counts = new Map(); + for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1); + const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key)); + const rows = inOrg.filter(a => !stateF || a.state === stateF); + const akey = a => `${a.org}:${a.key}`; + const orgChip = a => `${esc(o.name(a.org))}`; + const projOf = a => a.project || a.s?.project || null; + const projChip = a => (projOf(a)?.name ? `${esc(projOf(a).name)}` : ''); + const work = a => (a.s?.task ? `${esc(a.s.task.title || a.s.task.ref)}${a.s.task.title && a.s.task.ref ? `${esc(a.s.task.ref)}` : ''}` : 'nothing reported'); + const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' }; + const row = a => ` + ${esc(a.name)}${a.id ? `${esc(a.id)}` : ''}${orgChip(a)}${projChip(a)}${esc(a.role)}${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'} + ${esc(a.role)}${a.level !== 'org' ? `${esc(LEVEL_WORD[a.level])}` : ''} + ${dot(...a.dot, true)} + ${work(a)} + Open ${esc(a.name)} in ${esc(o.name(a.org))}${a.fire ? window.MosaicDecide?.fireControl(a.org, a.name, true) ?? '' : ''} + `; + // An organization that did not answer is named first, with the reason its read gave; one that + // answered but whose instances read failed is named too, never counted as having no agents. + const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`; + const failed = reads.filter(r => !r.inst && (!org || org.includes(r.id))); + const READ_ERR = { 403: 'refused', 0: 'signed out' }; + const unread = (o.unread.length ? `

Not read: ${andList(o.unread.map(why))}.

` : '') + + (failed.length ? `

Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(READ_ERR[r.err?.status] || r.err?.code || 'no answer')})`))}.

` : '') + + missLine(o, ['agents', 'tasks'], 'agents', 'What agents work on was not read in'); + const anyRead = reads.some(r => r.inst && (!org || org.includes(r.id))); + const filtered = (org && !scope) || stateF; + const table = rows.length + ? `
${rows.map(row).join('')}
Agents
AgentRoleStateWorking onActions
` + : `

${!anyRead && o.rows.length ? 'Agents not read' : filtered ? 'Nothing matches these filters' : 'No agents'}

`; + const stateCounts = new Map(); + for (const a of inOrg) stateCounts.set(a.state, (stateCounts.get(a.state) || 0) + 1); + const shown = AGENT_STATE.filter(([k]) => stateCounts.has(k) || k === stateF); + const stateChips = `
State${shown.map(([k, w]) => ``).join('')}
`; + // The org chips count each answering organization's agents; an organization whose instances read + // failed shows "not read", as one that did not answer shows its state. + const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id)); + const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) }; + const orgsWith = new Set(rows.map(a => a.org)).size; + const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : ''; + const sub = scope + ? `Agents in ${esc(o.name(scope))} only.${tally} Every organization` + : `Every agent on the system, by organization.${tally} Open one to see it in its organization.`; + return `
+

Agents

${sub}

+ ${unread} +
+ ${scope ? '' : `
Org${orgChips(chipOrgs, counts, org, all.length, ['instances'])}
`} + ${stateChips} +
+ ${table} + ${scope ? '' : `
${window.MosaicDecide?.fireNote('*') ?? ''}
`} +
`; + } + // ---- controls: filters write the hash query and repaint in place ---- const pathOf = () => route().split('?')[0]; const queryOf = () => new URLSearchParams(route().split('?')[1] || ''); @@ -895,8 +1010,10 @@ if (pathOf() === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); } document.addEventListener('click', e => { - const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox]'); + const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox],[data-ws-agents]'); if (!page) return; + const st = e.target.closest('[data-ws-astate]'); + if (st) { setQuery({ state: st.dataset.wsAstate || null }); return; } const chip = e.target.closest('[data-ws-org]'); if (chip) { const id = chip.dataset.wsOrg, now = listParam(queryOf(), 'org', x => ORG_ID.test(x)) || []; @@ -931,6 +1048,6 @@ // U4b: the org mark (colour and initials) for the task board (tasks-board.js). const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials }); - window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, inboxCount, setGrouping, get pollMs() { return pollMs; } }); + window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, get pollMs() { return pollMs; } }); refresh(); })(); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 5a978e706..5ba38b8cb 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -71,7 +71,9 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = // SP3 (#1873): an answer from the system Inbox goes to its row's organization. decideBase is // /api/orgs/ for such an answer and /api otherwise, so the route is still a decision's // confirmation or resolve, only pinned to one organization. - assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']); + // SP5 (#1873): Fire from the system Agents page goes to its row's organization the same way: + // decideBase({org}) is /api/orgs/, and an agent card's Fire (no org) keeps /api. + assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "`${decideBase({ org: named })}/decisions/hire/start`", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']); assert.deepEqual([...read('bus.js').toString().matchAll(/const decideBase = [^;]*;/g)].map(m => m[0]), ["const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';"]); // Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal. // Row 176 (#1674): the tester name form adds a second /api/name caller. diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs index a8923ce6f..ed779ffad 100644 --- a/packages/webui/tests/single-pane-browser.test.mjs +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -130,8 +130,12 @@ test('an organization opens inside the same shell with a breadcrumb, and its pag await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()'); await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"'); assert.equal(await b.evaluate('window.__marker'), 1); - // A page inside beta scopes its reads to beta. + // #1873 SP5: #/agents is the system Agents page, not beta's; it keeps the address and opens no organization. await b.evaluate('location.hash = "#/agents"'); + await wait('location.hash === "#/agents" && document.querySelector("[data-ws-agents]:not([data-ws-scope])")'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta'); + // A page inside beta scopes its reads to beta. + await b.evaluate('location.hash = "#/orgs/beta/agents"'); await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")'); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta'); assert.equal(await b.evaluate('window.__marker'), 1); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs new file mode 100644 index 000000000..b429e079f --- /dev/null +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -0,0 +1,210 @@ +// SP5 (#1873, single pane slice 5): one Agents page for the whole system at #/agents. Every +// organization that answers adds its agents to one table: name, role, state, what it works on, its +// organization chip and a project chip when the work has a project. Filters by organization and state +// live in the hash query. Fire on a row goes to the row's own organization, never to the one opened +// last, and is offered only where that organization's own config says this Console decides. A stopped +// organization is named at the top with its reason. #/orgs//agents shows the same table, scoped. +// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and +// the real per-organization routes serve the page. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +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)})})()`); +const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); + +const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b'; +function fixture({ empty = false } = {}) { + const LIST = [ + { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, + { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, + { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false }, + ]; + const DATA = { + acme: { + projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }], + // A built-in store task assigned to coder, filed under the Website project. + tasks: [{ task_ref: 'store:1', source: 'store', at: at(20), fields: { title: 'Hero copy', state: 'in-progress', group: 'p-site', assignees: ['coder'] } }], + agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }], + instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } }, + }, + beta: { + projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], + tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], + instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } }, + }, + }; + if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; } + const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); + const opts = id => ({ + bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, + chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') }, + }); + const member = org => ({ + id: org.id, doc: { appearance: { theme: org.theme } }, + status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + options: () => opts(org.id), + }); + return { + list: async () => LIST, + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; + return { + busHealthy: true, + reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, + bus: { + messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], + instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances }), + }, + chat: { projects: async () => DATA[id].projects, threads: async () => [] }, + }; + }, + }; +} + +// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every +// organization's config says this Console decides there, except those named in `noDecide`; decision +// POSTs are recorded and answered from a queue, so no real write happens. +const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`; +const STUB = `(() => { + const real = window.fetch.bind(window); + window.__posts = []; window.__answers = []; + const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + window.fetch = async (url, init = {}) => { + const u = String(url); + if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) { + const c = await (await real(url, init)).json(); + const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1]; + return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) }); + } + if (!u.includes('/decisions/')) return real(url, init); + window.__posts.push({ url: u, method: init.method, body: init.body }); + const a = window.__answers.shift(); + if (!a) throw new TypeError('no answer queued'); + return answer(a.status ?? 200, a.body); + }; +})();`; + +async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, stub = false, noDecide = [] } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); + t.after(() => close(server)); + const b = await browser({ machine: false }); t.after(() => b.close()); + await b.viewport(...viewport); + if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) }); + const base = `http://127.0.0.1:${server.address().port}`; + await b.navigate(`${base}/${hash}`); + return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; +} +const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))'; +const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`; +const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2']; + +test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => { + const { b, wait, text } = await setup(t); + await wait(are(ALL)); + // A system route: the address keeps no organization and the sidebar Agents link is this page. + assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents'); + assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents'); + assert.equal(await text('#bus-view h1'), 'Agents'); + assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio'); + assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works'); + // State from the bus, and what the agent works on: its task, with the task's project chip. + assert.match(await text('[data-ws-agent="acme:coder"]'), /working/); + assert.match(await text('[data-ws-agent="beta:pm"]'), /waiting on you/); + assert.match(await text('[data-ws-agent="acme:coder"] [data-ws-work]'), /Hero copy/); + assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-projchip]'), 'Website'); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-ws-projchip]\')'), null); + // A hired agent reads by its name and role; the definition id stays as secondary text. + const hired = await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] .t1\')?.firstChild?.textContent'); + assert.equal(hired, 'research-2'); + assert.match(await text('[data-ws-agent="beta:research-2"] [data-ws-role]'), /Hired.*Coding/); + assert.match(await text('[data-ws-agent="beta:research-2"] .t2'), new RegExp(`org:hire-${HIRE}`)); + assert.match(await text('[data-ws-agent="acme:chief"] [data-ws-role]'), /Chief of staff/); + // Each row opens the agent in its own organization. + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents'); +}); + +test('the organization and state filters live in the hash and survive a reload', async t => { + const { b, base, wait } = await setup(t); + await wait(are(ALL)); + await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=beta]").click()'); + await wait(`location.hash.startsWith("#/agents?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${are(['beta:pm', 'beta:research-2'])}`); + await b.navigate(`${base}/${await b.evaluate('location.hash')}`); + await wait(`${are(['beta:pm', 'beta:research-2'])} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`); + // State: one chip per state the rows carry. + await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=\'\']").click()'); + await wait(are(ALL)); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-astate]")].map(x => x.dataset.wsAstate)'), ['', 'working', 'waiting', 'not-running']); + await b.evaluate('document.querySelector("[data-ws-astate=not-running]").click()'); + await wait(`new URLSearchParams(location.hash.split("?")[1]).get("state") === "not-running" && ${are(['acme:chief', 'beta:research-2'])}`); + await b.navigate(`${base}/${await b.evaluate('location.hash')}`); + await wait(`${are(['acme:chief', 'beta:research-2'])} && document.querySelector("[data-ws-astate=not-running]")?.getAttribute("aria-pressed") === "true"`); + // A value the page does not know is dropped, never read as a filter. + await b.evaluate('location.hash = "#/agents?state=bogus&org=Not%20An%20Org"'); + await wait(are(ALL)); + await b.evaluate('location.hash = "#/agents?org=beta&state=working"'); + await wait('document.querySelector("[data-ws-agent-list]")?.textContent === "Nothing matches these filters"'); +}); + +test('firing beta\'s agent from #/agents while acme was opened last reaches beta', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true }); + await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); + await b.evaluate('location.hash = "#/agents"'); + await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); + // Never on the chief. + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="acme:chief"] [data-agent-fire]\')'), null); + await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\').click()'); + await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\')'); + await b.evaluate('window.__answers.push({ body: { id: "dec-fire-1", op: "resolved", choice: "yes" } })'); + await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\').click()'); + await wait('/Fired pm/.test(document.querySelector("[data-ws-agents] [data-agents-receipt]")?.textContent ?? "")'); + const posts = await b.evaluate('window.__posts'); + assert.deepEqual(posts.map(p => p.url), ['/api/orgs/beta/decisions/hire/start']); + assert.deepEqual(JSON.parse(posts[0].body), { business: 'beta', hire: { op: 'fire', level: 'org', name: 'pm', reason: 'Fired from the Agents page.' } }); + assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /dec-fire-1/); + assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); +}); + +test('Fire follows each organization\'s own config, not the organization opened last', async t => { + // Acme is opened last and this Console decides there; beta's own config says it does not. + const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['beta'] }); + await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); + await b.evaluate('location.hash = "#/agents"'); + await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="acme:coder"] [data-agent-fire]')`); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\')'), null); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-agent-fire]\')'), null); +}); + +test('a stopped organization is named at the top with its reason', async t => { + const { b, wait, text } = await setup(t); + await wait(are(ALL)); + const note = await text('[data-ws-unread]'); + assert.match(note, /Gamma Home/); + assert.match(note, /stopped by the owner/); + assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-agent-list]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true); +}); + +test('with no agents anywhere the table says so and still names the stopped organization', async t => { + const { wait, text } = await setup(t, { empty: true }); + await wait('document.querySelector("[data-ws-agent-list]")'); + assert.equal(await text('[data-ws-agent-list]'), 'No agents'); + assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s); +}); + +test('#/orgs//agents shows that organization\'s rows only, with a link to every organization', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/agents' }); + await wait(are(['beta:pm', 'beta:research-2'])); + assert.match(await text('[data-ws-agents] .sub'), /Beta Works/); + assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true); + assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents'); + // The organization's own levels grid and held roles still follow, under the one page heading. + await wait('document.querySelector("[data-org-agents]")'); + assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1); +}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 8c121a7b3..b11aa6699 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -484,7 +484,8 @@ test('agents grid shows each agent\'s level and where it is defined, per project assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/); // Home, where the reviewer is turned off: the table says so. await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`); - await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"'); + // #1873 SP5: the scoped page puts the levels grid under its own "Agents by level" h2; this is the section's title. + await waitFor(b)('document.querySelector("[data-org-agents] .ws-split-agents h2")?.innerText === "Agents in Home"'); assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/); }); -- 2.54.0 From eebdb2f0ce7bdf89909c928a5de4543a9d0d2bfc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 03:26:18 -0600 Subject: [PATCH 2/3] fix(webui): sp5 review round, Open addresses the agent and the scoped page marks it, Fire words name the organization, a named Fire keeps its outcome, Cancel restores focus (#1873, lane sp5) - Open on #/agents goes to #/orgs//agents?agent=; the scoped table puts that agent first, marks it (aria-current) and draws no Open or Actions column (review F1, F2). - Fire's aria-label, confirm group and receipt use the organization's label (F5). - A Fire sent to a named organization keeps its receipt after the opened organization changes (F7). - Cancel moves focus back to the row's Fire (F10). - agentsPage refuses a scope that is not an organization id instead of listing every organization (F11). --- packages/webui/src/public/bus.js | 28 ++++++++------- packages/webui/src/public/shell.css | 1 + packages/webui/src/public/workspace.js | 17 +++++++--- .../tests/system-agents-browser.test.mjs | 34 ++++++++++++++++++- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 502cd003b..b0ad1875e 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -496,7 +496,7 @@ return r?`

${txt(r.text)}${r.code?` (${txt(r.code)})`:''}

`:'';} function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());} const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`; - async function fireAgent(name,named=null){ + async function fireAgent(name,named=null,orgName=named){ const org=named??hireOrg(); // A named organization was checked against its own config when the control was drawn (decidesIn). if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)return; @@ -504,10 +504,12 @@ for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true; const epoch=orgEpoch; const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}}); - if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;} + // SP5 review F7: a named organization's Fire went to that organization, so its outcome still + // stands after the opened organization changes. + if(epoch!==orgEpoch&&!named){if(res?.type==='opaqueredirect')signOut();return;} fireBusy=false;fireAsk=null; if(res?.type==='opaqueredirect'){signOut();return;} - const where=named?` in ${org}`:''; + const where=named?` in ${orgName}`:''; if(res?.ok && json?.op==='resolved'){ for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove(); for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove(); @@ -519,28 +521,30 @@ const r=decideRefusal(res,json,'outcome-unknown'); fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code}; paintFireNote(); - for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named); + for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName); } } const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:''; const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`); - const fireButton=(name,org=null)=>``; - const fireConfirm=(name,org=null)=>`Fire ${txt(name)}? `; + // SP5 review F5: the words name the organization by its label (`orgName`); its id addresses the control. + const fireNameAttr=(org,orgName)=>org&&orgName&&orgName!==org?` data-agent-fire-name="${esc(orgName)}"`:''; + const fireButton=(name,org=null,orgName=org)=>``; + const fireConfirm=(name,org=null,orgName=org)=>`Fire ${txt(name)}? `; // What the agent card shows for one agent: nothing without the decide right or outside the page's own // organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn); // the control is then drawn for that organization, whichever one was opened last. - function fireControl(org,name,allowed){ + function fireControl(org,name,allowed,orgName=org){ const named=typeof allowed==='boolean'; if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return ''; - return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null):fireButton(name,named?org:null); + return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null,named?orgName:null):fireButton(name,named?org:null,named?orgName:null); } document.addEventListener('click',e=>{ const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]'); if(!t)return; - const named=t.dataset.agentFireOrg??null,org=named??hireOrg(); - if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();} - else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named);announce('Cancelled. Nothing was fired.');} - else void fireAgent(t.dataset.agentFireYes,named); + const named=t.dataset.agentFireOrg??null,org=named??hireOrg(),orgName=t.closest('[data-agent-fire-name]')?.dataset.agentFireName??named; + if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named,orgName);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();} + else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named,orgName);document.querySelector(fireSel('data-agent-fire',name,named))?.focus();announce('Cancelled. Nothing was fired.');} + else void fireAgent(t.dataset.agentFireYes,named,orgName); }); window.MosaicDecide=Object.freeze({fireControl,fireNote}); document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);}); diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 80a498fe7..d5eb75c6c 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -699,6 +699,7 @@ html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px} html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)} html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal} html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere} +html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))} html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))} html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none} html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 3d82f4265..f3fd6aeca 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -902,7 +902,9 @@ const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' }; async function agentsPage(q, opts = {}) { - const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null; + // SP5 review F11: a scope that is not an organization id is refused, never widened to the system list. + if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '

Agents

That is not an organization address.

'; + const scope = opts.scope ?? null; const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id)); const want = String(q?.get?.('state') || ''); const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null; @@ -942,19 +944,24 @@ const counts = new Map(); for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1); const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key)); - const rows = inOrg.filter(a => !stateF || a.state === stateF); + const filteredRows = inOrg.filter(a => !stateF || a.state === stateF); + // SP5 review F1/F2: Open addresses one agent (`#/orgs//agents?agent=`). The scoped page puts + // that agent first and marks it, and draws no Open of its own, which would only link to itself. + const picked = scope ? String(q?.get?.('agent') || '') : ''; + const hit = picked ? filteredRows.find(a => a.key === picked) || null : null; + const rows = hit ? [hit, ...filteredRows.filter(a => a !== hit)] : filteredRows; const akey = a => `${a.org}:${a.key}`; const orgChip = a => `${esc(o.name(a.org))}`; const projOf = a => a.project || a.s?.project || null; const projChip = a => (projOf(a)?.name ? `${esc(projOf(a).name)}` : ''); const work = a => (a.s?.task ? `${esc(a.s.task.title || a.s.task.ref)}${a.s.task.title && a.s.task.ref ? `${esc(a.s.task.ref)}` : ''}` : 'nothing reported'); const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' }; - const row = a => ` + const row = a => ` ${esc(a.name)}${a.id ? `${esc(a.id)}` : ''}${orgChip(a)}${projChip(a)}${esc(a.role)}${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'} ${esc(a.role)}${a.level !== 'org' ? `${esc(LEVEL_WORD[a.level])}` : ''} ${dot(...a.dot, true)} ${work(a)} - Open ${esc(a.name)} in ${esc(o.name(a.org))}${a.fire ? window.MosaicDecide?.fireControl(a.org, a.name, true) ?? '' : ''} + ${scope ? '' : `Open ${esc(a.name)} in ${esc(o.name(a.org))}${a.fire ? window.MosaicDecide?.fireControl(a.org, a.name, true, o.name(a.org)) ?? '' : ''}`} `; // An organization that did not answer is named first, with the reason its read gave; one that // answered but whose instances read failed is named too, never counted as having no agents. @@ -967,7 +974,7 @@ const anyRead = reads.some(r => r.inst && (!org || org.includes(r.id))); const filtered = (org && !scope) || stateF; const table = rows.length - ? `
${rows.map(row).join('')}
Agents
AgentRoleStateWorking onActions
` + ? `
${scope ? '' : ''}${rows.map(row).join('')}
Agents
AgentRoleStateWorking onActions
` : `

${!anyRead && o.rows.length ? 'Agents not read' : filtered ? 'Nothing matches these filters' : 'No agents'}

`; const stateCounts = new Map(); for (const a of inOrg) stateCounts.set(a.state, (stateCounts.get(a.state) || 0) + 1); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index b429e079f..be3941798 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -126,7 +126,7 @@ test('#/agents lists both organizations\' agents in one table, each row with its assert.match(await text('[data-ws-agent="beta:research-2"] .t2'), new RegExp(`org:hire-${HIRE}`)); assert.match(await text('[data-ws-agent="acme:chief"] [data-ws-role]'), /Chief of staff/); // Each row opens the agent in its own organization. - assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents'); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm'); }); test('the organization and state filters live in the hash and survive a reload', async t => { @@ -164,6 +164,8 @@ test('firing beta\'s agent from #/agents while acme was opened last reaches beta await b.evaluate('window.__answers.push({ body: { id: "dec-fire-1", op: "resolved", choice: "yes" } })'); await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\').click()'); await wait('/Fired pm/.test(document.querySelector("[data-ws-agents] [data-agents-receipt]")?.textContent ?? "")'); + // The receipt names the organization by its label, not its id. + assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /^Fired pm in Beta Works\./); const posts = await b.evaluate('window.__posts'); assert.deepEqual(posts.map(p => p.url), ['/api/orgs/beta/decisions/hire/start']); assert.deepEqual(JSON.parse(posts[0].body), { business: 'beta', hire: { op: 'fire', level: 'org', name: 'pm', reason: 'Fired from the Agents page.' } }); @@ -208,3 +210,33 @@ test('#/orgs//agents shows that organization\'s rows only, with a link to ev await wait('document.querySelector("[data-org-agents]")'); assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1); }); + +test('Open addresses the agent: the scoped page puts it first, marks it and draws no Open of its own', async t => { + const { b, wait } = await setup(t); + await wait(are(ALL)); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').getAttribute("href")'), '#/orgs/beta/agents?agent=research-2'); + await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').click()'); + await wait(are(['beta:research-2', 'beta:pm'])); + assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents?agent=research-2'); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"]\').getAttribute("aria-current")'), 'true'); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"]\').hasAttribute("aria-current")'), false); + // No self-link on the scoped page. + assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] [data-ws-open]").length'), 0); + assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] thead th").length'), 4); + // An agent the organization does not have marks nothing. + await b.evaluate('location.hash = "#/orgs/beta/agents?agent=nobody"'); + await wait(`${are(['beta:pm', 'beta:research-2'])} && !document.querySelector('[data-ws-agents] [aria-current]')`); +}); + +test('Cancel puts focus back on the row\'s Fire, which is labelled with the organization\'s name', async t => { + const { b, wait } = await setup(t, { stub: true }); + await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`); + const fire = '\'[data-ws-agent="beta:pm"] [data-agent-fire]\''; + assert.equal(await b.evaluate(`document.querySelector(${fire}).getAttribute('aria-label')`), 'Fire pm in Beta Works'); + await b.evaluate(`document.querySelector(${fire}).click()`); + await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\')'); + assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-confirm]\').getAttribute("aria-label")'), 'Fire pm in Beta Works?'); + await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\').click()'); + await wait(`document.activeElement === document.querySelector(${fire})`); + assert.deepEqual(await b.evaluate('window.__posts'), []); +}); -- 2.54.0 From f97e0d3aebd116d44d538710a011292090bf39df Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 03:32:32 -0600 Subject: [PATCH 3/3] fix(webui): sp5 re-check notes, a late named Fire leaves a newer Fire's busy state alone, the picked agent row gets the left bar, tests for the picked agent with the state filter and a refused scope (#1873, lane sp5) --- packages/webui/src/public/bus.js | 6 ++++-- packages/webui/src/public/shell.css | 2 ++ .../webui/tests/system-agents-browser.test.mjs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index b0ad1875e..579315329 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -506,8 +506,10 @@ const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}}); // SP5 review F7: a named organization's Fire went to that organization, so its outcome still // stands after the opened organization changes. - if(epoch!==orgEpoch&&!named){if(res?.type==='opaqueredirect')signOut();return;} - fireBusy=false;fireAsk=null; + // After a switch, busy and confirm state belong to whatever Fire started since; leave them alone. + const stale=epoch!==orgEpoch; + if(stale&&!named){if(res?.type==='opaqueredirect')signOut();return;} + if(!stale){fireBusy=false;fireAsk=null;} if(res?.type==='opaqueredirect'){signOut();return;} const where=named?` in ${orgName}`:''; if(res?.ok && json?.op==='resolved'){ diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index d5eb75c6c..7b5cb57f9 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -700,6 +700,8 @@ html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 1 html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal} html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere} html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))} +html[data-organizations] .ws-agt tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)} +@media (forced-colors:active){html[data-organizations] .ws-agt tr.on td:first-child{border-left:3px solid Highlight}} html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))} html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none} html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px} diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index be3941798..c696e45b5 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -240,3 +240,19 @@ test('Cancel puts focus back on the row\'s Fire, which is labelled with the orga await wait(`document.activeElement === document.querySelector(${fire})`); assert.deepEqual(await b.evaluate('window.__posts'), []); }); + +test('a picked agent stays in the address through the state filter, and a scope that is not an organization id is refused', async t => { + const { b, wait } = await setup(t, { hash: '#/orgs/beta/agents?agent=research-2' }); + await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agents] [data-ws-astate="waiting"]')`); + await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate="waiting"]\').click()'); + await wait(are(['beta:pm'])); + const q = new URLSearchParams((await b.evaluate('location.hash')).split('?')[1]); + assert.equal(q.get('agent'), 'research-2'); + assert.equal(q.get('state'), 'waiting'); + assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] [aria-current]")'), null); + await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate=""]\').click()'); + await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agent="beta:research-2"][aria-current="true"]')`); + const refused = await b.evaluate('window.MosaicWorkspace.agentsPage(new URLSearchParams(), { scope: "../beta" })'); + assert.match(refused, /data-ws-failed/); + assert.doesNotMatch(refused, /data-ws-agent=/); +}); -- 2.54.0