diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 6483777..548dade 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -658,6 +658,50 @@ test("B1: a reply being written draws the stream's progress, its clock moving, a assert.deepEqual(p.errors, []) }) +// --- C4 (ahead of the server): the subagent tree as spec 3.4.0 §12 gives it --------------------- +// +// ui/js/tree.js draws subagents.md §12's data; the activity panel takes it up once the server's +// /api/v2 carries the tree. Until then it is checked here on the spec's own example. + +const TREE = { + min: 2, max: 4, size: 3, open_slots: 1, + agents: [ + { id: "lead", name: "lead", position: "the main agent", parent: null, state: "working", model: "qwen-27b", mode: "edit", request: [], manager: true, context: { used: 41200, window: 131072 }, progress: { phase: "thinking", since: 0 } }, + { id: "s1", name: "parser", position: "reviews the parser", parent: "lead", state: "working", model: "qwen-27b", mode: "manual", request: ["write"], manager: true, task: "Read src/parse.ts", context: { used: 9100, window: 131072 }, progress: { phase: "running_tool", since: 0, tool: "read", what: "src/parse.ts" }, findings: 2, messages: 3 }, + { id: "s3", name: "lexer", position: "the lexer's tests", parent: "s1", state: "waiting", model: "qwen-27b", mode: "manual", request: [], manager: false, context: { used: 0, window: 131072 }, progress: null, findings: 0, messages: 0 }, + ], + ended: [{ id: "s0", name: "scout", position: "maps the repository", parent: "lead", state: "out_of_context", ended: 0, findings: 1 }], +} + +test("C4: the tree in order, each agent's position, state, mode, context and phase; slots; the ended ones apart", async () => { + const p = await page(b3()) + const got = await p.evaluate(async (tree) => { + const m = await import("/js/tree.js") + const calls = [] + const box = m.treePart(tree, { room: [{ n: 1, from: "lead", to: ["parser"], text: "Start with the grammar.\nThen the lexer." }], now: 65000, act: { open: (a) => calls.push(["open", a.id]), findings: (a) => calls.push(["findings", a.id]), stop: (a) => calls.push(["stop", a.id]) } }) + document.body.append(box) + const rows = [...box.querySelectorAll(".subagents__list > .subagent:not(.subagent--slot)")].map((r) => ({ id: r.dataset.id, depth: r.style.getPropertyValue("--depth"), text: r.textContent })) + box.querySelector('[data-id="s1"] .subagent__actions .btn').click() + box.querySelector('[data-id="s0"] .subagent__actions .btn:last-child').click() + box.querySelector('[data-id="s1"] .agent__stop').click() + return { rows, slots: box.querySelectorAll(".subagent--slot").length, endedStop: box.querySelectorAll('[data-id="s0"] button.agent__stop').length, calls } + }, TREE) + assert.deepEqual(got.rows.map((r) => [r.id, r.depth]), [["lead", "0"], ["s1", "1"], ["s3", "2"], ["s0", "0"]]) + const parser = got.rows[1].text + assert.match(parser, /parser \(s1\) — reviews the parser/) + assert.match(parser, /6%/, "context: floor(9100 × 100 / 131072)") + assert.match(parser, /manual \+ write/) + assert.match(parser, /Running read: src\/parse\.ts · 1m 05s/) + assert.match(parser, /Messages: 3/) + assert.match(parser, /lead: Start with the grammar\./) + assert.match(got.rows[2].text, /waiting for a slot/) + assert.match(got.rows[3].text, /out of context/) + assert.equal(got.slots, 1) + assert.equal(got.endedStop, 0, "an ended subagent cannot be stopped") + assert.deepEqual(got.calls, [["open", "s1"], ["findings", "s0"], ["stop", "s1"]]) + assert.deepEqual(p.errors, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/js/tree.js b/ui/js/tree.js new file mode 100644 index 0000000..58e4174 --- /dev/null +++ b/ui/js/tree.js @@ -0,0 +1,192 @@ +// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it: the +// main agent at the root, every subagent under its parent in the order added, the open slots, +// then the ended ones, dimmed, with how each ended. Per agent: its name and id, its position, +// its state, model, mode (and request options), how full its context is, its current phase (the +// progress line), its messages in the room, and -- for any subagent, live or ended -- its session +// read-only and its findings; a live one can be stopped. +// +// The data is the spec's, as the server passes it on in /api/v2 for a chat's own subagents and +// for an agent chat's alike: +// +// { min, max, size, open_slots, +// agents: [{ id, name, position, parent, state, model, mode, request, manager, task, +// context: { used, window }, progress, findings, messages }], +// ended: [{ id, name, position, parent, state, ended, findings }] } +// +// Everything in it but the numbers is a model's words: set as text, never as markup. + +import { h, icon, ICONS } from "./dom.js" +import { progressLine } from "./progress.js" + +/** The states' words (loop.json team.read.states). */ +const STATES = { + working: "working", + waiting: "waiting for a slot", + finished: "finished", + idle: "idle", + paused: "paused: context full", + done: "done", + out_of_context: "out of context", + stopped: "stopped", +} +export const TREE_SENTENCES = Object.values(STATES) + +export function treeState(state) { + return STATES[state] ? t(STATES[state]) : String(state || "") +} + +/** floor(used × 100 / window), or null when the window is not known. */ +export function contextPercent(c) { + const used = Number(c?.used) + const window = Number(c?.window) + if (!(window > 0) || !(used >= 0)) return null + return Math.min(100, Math.floor((used * 100) / window)) +} + +function modeWords(a) { + if (!a.mode) return "" + const req = (a.request || []).filter(Boolean) + return req.length ? `${a.mode} ${req.map((r) => `+ ${r}`).join(" ")}` : a.mode +} + +/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */ +export function treeOrder(agents) { + const ids = new Set(agents.map((a) => a.id)) + const under = new Map() + for (const a of agents) { + const parent = a.parent && ids.has(a.parent) && a.parent !== a.id ? a.parent : null + if (!under.has(parent)) under.set(parent, []) + under.get(parent).push(a) + } + const out = [] + const seen = new Set() + const walk = (parent, depth) => { + for (const a of under.get(parent) || []) { + if (seen.has(a.id)) continue + seen.add(a.id) + out.push({ agent: a, depth }) + walk(a.id, depth + 1) + } + } + walk(null, 0) + for (const a of agents) if (!seen.has(a.id)) out.push({ agent: a, depth: 0 }) + return out +} + +/** The room's messages to or from an agent (by name; `all` reaches everyone). */ +function messagesOf(room, a) { + return room.filter((m) => m.from === a.name || (m.to || []).includes(a.name) || (m.recipients || []).includes(a.name)) +} + +function firstLine(text) { + return String(text || "").split("\n").find((l) => l.trim()) || "" +} + +/** + * The tree as rows. `act` holds what the rows can do: `open(a)` (its session, read-only), + * `findings(a)`, `stop(a)`; `isOpen(key)` / `toggled(key, open)` keep unfolded rows unfolded + * across redraws; `room` is the room as read so far. + */ +export function treePart(tree, { room = [], act = {}, now = Date.now() } = {}) { + const rows = [] + for (const { agent, depth } of treeOrder(tree.agents || [])) rows.push(agentRow(agent, depth, { room, act, now, ended: false })) + for (let i = 0; i < (Number(tree.open_slots) || 0); i++) { + rows.push(h("div", { class: "subagent subagent--slot", style: `--depth: 1` }, h("span", { class: "subagent__slot", text: t("Open slot") }))) + } + const ended = tree.ended || [] + const box = h("div", { class: "subagents" }) + box.append( + h("p", { class: "subagents__size", text: t("%(size)s of at most %(max)s agents", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }), + h("div", { class: "subagents__list" }, rows), + ) + if (ended.length) { + const key = "fold:ended" + const fold = h( + "details", + { class: "tasks__more subagents__ended", open: act.isOpen?.(key) }, + h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: t("Ended %(n)s", { n: ended.length }) })), + h( + "div", + { class: "subagents__list" }, + ended.map((a) => agentRow(a, 0, { room, act, now, ended: true })), + ), + ) + fold.addEventListener("toggle", () => act.toggled?.(key, fold.open)) + box.append(fold) + } + return box +} + +function agentRow(a, depth, { room, act, now, ended }) { + const key = `sub:${a.id}` + const main = !a.parent && a.id === "lead" + const pct = contextPercent(a.context) + const line = a.progress ? progressLine(a.progress, now) : "" + const said = messagesOf(room, a) + const last = said[said.length - 1] + const stop = + !ended && !main && act.stop + ? h( + "button", + { + type: "button", + class: "agent__stop", + "aria-label": t("Stop %(name)s", { name: a.name }), + title: t("Stop %(name)s", { name: a.name }), + onclick: (e) => { + e.preventDefault() + e.stopPropagation() + act.stop(a) + }, + }, + h("span", { class: "agent__square", "aria-hidden": "true" }), + ) + : h("span", { class: "agent__stop is-none", "aria-hidden": "true" }) + const el = h( + "details", + { class: `agent subagent is-${String(a.state || "").replace(/[^a-z_]/g, "")}${ended ? " is-ended" : ""}`, open: act.isOpen?.(key), dataset: { id: a.id } }, + h( + "summary", + { class: "agent__row" }, + h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), + h( + "span", + { class: "agent__what", title: a.position || a.name }, + h("bdi", { class: "agent__name", text: a.name || a.id }), + h("span", { class: "subagent__id", text: ` (${a.id})` }), + a.position ? h("span", { class: "subagent__position", text: ` — ${main ? t("the main agent") : a.position}` }) : null, + ), + h("span", { class: "subagent__pct", text: pct === null ? "" : `${pct}%`, title: pct === null ? "" : t("%(percent)s%% of the context window used", { percent: pct }).replace(/%%/g, "%") }), + stop, + ), + h( + "div", + { class: "agent__body" }, + h( + "p", + { class: "agent__facts" }, + h("span", { class: "agent__state", text: treeState(a.state) }), + a.model ? h("span", { class: "agent__model", text: a.model }) : null, + modeWords(a) ? h("span", { class: "subagent__mode", text: modeWords(a) }) : null, + a.findings ? h("span", { text: t("Findings: %(n)s", { n: a.findings }) }) : null, + ), + pct === null ? null : h("div", { class: "subagent__bar", role: "meter", "aria-valuemin": "0", "aria-valuemax": "100", "aria-valuenow": String(pct), "aria-label": t("Context") }, h("span", { style: `--pct: ${pct}%` })), + line ? h("p", { class: "subagent__phase", "data-phase": a.progress?.phase || "", text: line }) : null, + a.task ? h("p", { class: "agent__brief", text: a.task }) : null, + said.length || a.messages + ? h("p", { class: "subagent__room" }, h("span", { class: "chip", text: t("Messages: %(n)s", { n: a.messages ?? said.length }) }), last ? h("span", { class: "subagent__last", text: `${last.from}: ${firstLine(last.text)}` }) : null) + : null, + main + ? null + : h( + "div", + { class: "subagent__actions" }, + act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its session"), onclick: () => act.open(a) }) : null, + act.findings ? h("button", { type: "button", class: "btn btn--sm", text: t("Findings"), onclick: () => act.findings(a) }) : null, + ), + ), + ) + el.style.setProperty("--depth", String(Math.max(0, Math.min(6, depth)))) + el.addEventListener("toggle", () => act.toggled?.(key, el.open)) + return el +}