Files
stack/packages/webui/tests/system-agents-browser.test.mjs

259 lines
18 KiB
JavaScript

// 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/<id>/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?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/<id>/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);
});
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=/);
});