No organizations yet, so there is nothing to count.
' : `
- ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
+ ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })}
${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })}
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })}
@@ -679,15 +689,17 @@
// SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an
// organization that did not answer shows its state and reason instead of figures.
- const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions);
+ const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs);
const none = '
–';
const orgTable = o.rows.map(r => {
const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id);
const [cls, glyph, word] = rowState(r, fromList);
const lv = levels?.orgs?.find(v => v?.id === id);
- const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
- const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null;
- const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : `
(${working} working)`}`;
+ // SP11: an organization whose agents were read counts the tile's rows, running and working; one
+ // that was not (a stopped bus) counts the agents in its file and says nothing of what runs.
+ const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null;
+ const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
+ const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? `
(${runningOf(mineRows)} running, ${workingOf(mineRows)} working)` : ''}`;
const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none;
const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none;
// #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once.
@@ -950,7 +962,7 @@
// `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 = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['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' };
// Each named organization's instances read, and whether this Console decides there when `fireIn` is given.
const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([
@@ -1044,7 +1056,8 @@
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')}`}.` : '';
+ // SP11: running and working in the Dashboard tiles' words, over the rows shown.
+ const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}: ${runningOf(rows)} running, ${workingOf(rows)} working.` : '';
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.`;
diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs
index 16adcf8ad..63d06cac0 100644
--- a/packages/webui/tests/kpi-tiles-browser.test.mjs
+++ b/packages/webui/tests/kpi-tiles-browser.test.mjs
@@ -200,6 +200,30 @@ test('An organization page leads with its own tiles, each linking to the system
await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
});
+// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live
+// process, whatever it is doing; working is a running agent whose role session is working. The tile,
+// the Dashboard organization rows and the sessions heading count the same agent rows.
+const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`;
+const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`;
+test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => {
+ const { b, wait, text } = await setup(t);
+ await wait(homeReady);
+ // acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing.
+ assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
+ assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you');
+ assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)');
+ assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)');
+ await b.evaluate('location.hash = "#/orgs/beta"');
+ await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
+ assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
+ assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you');
+ assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/);
+ await b.evaluate('location.hash = "#/orgs/acme"');
+ await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`);
+ assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running');
+ assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/);
+});
+
test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' });
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');
diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs
index 0b153b08d..914c31f3c 100644
--- a/packages/webui/tests/system-agents-browser.test.mjs
+++ b/packages/webui/tests/system-agents-browser.test.mjs
@@ -17,7 +17,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++)
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
-function fixture({ empty = false } = {}) {
+function fixture({ empty = false, unheld = false } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
@@ -38,6 +38,8 @@ function fixture({ empty = false } = {}) {
},
};
if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; }
+ // `unheld`: a live designer run that holds no role session, so the bus reports no state for it.
+ if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] };
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') },
@@ -90,8 +92,8 @@ const STUB = `(() => {
};
})();`;
-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: () => {} });
+async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, unheld = false, stub = false, noDecide = [] } = {}) {
+ const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
@@ -132,6 +134,19 @@ test('#/agents lists both organizations\' agents in one table, each row with its
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm');
});
+// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard
+// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running"
+// that reads as every running agent.
+test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => {
+ const { b, wait, text } = await setup(t, { unheld: true });
+ await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2']));
+ assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./);
+ assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1');
+ await b.evaluate('document.querySelector("[data-ws-astate=working]").click()');
+ await wait(are(['acme:coder']));
+ assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./);
+});
+
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));