// 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 trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; 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'); // SP7: the page name shows once, as the heading; a trail with nothing above it is not drawn. assert.equal(await b.evaluate(trail), null); 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?agent=pm'); }); 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 ?? "")'); // 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.' } }); 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'); // SP7: a drill-in keeps its trail back to the dashboard and the organization. await wait(`${trail} === 'Dashboard / Beta Works / 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); }); 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'), []); }); 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=/); });