diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index fc92ba5a..2eeb347c 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -527,18 +527,41 @@ restarts the agent after its current turn. The panel reads the reason and a **retry** button. `swarmctl agent permissions set` sets the same grant from a shell. +### Creating an agent + +The **+ agent** button opens a dialog of five steps, which you go through in +order with **next** and can step back through with **back** or the step tabs: + +1. **identity**: the name, the hive, and optionally an + [archetype](archetypes.md) that fills in the settings of the steps after it. + **next** waits for a valid name (1-63 lowercase letters, digits and hyphens) + and a hive. +2. **instructions**: the text the agent's `AGENTS.md` starts with, in a box + of its own. +3. **permissions**: one checkbox per permission, starting from the default set + or the archetype's. +4. **resources and inference**: the optional resource caps and inference + settings. +5. **summary**: every setting the request will carry, and what stays at its + default, then **what this creates**. **create** is on this step only. + +The header, the step tabs and the buttons stay in place and only the step's own +fields scroll, so a long step never pushes **next** out of view. After +**create**, the dialog says it queued the job and links to **jobs**, and offers +**create another** and **close**. If the controller refuses the `AGENTS.md` text, +the dialog goes back to step 2 and shows the reason there. + ### Settings at creation -The **+ agent** dialog has two collapsed sections, **resource caps** and -**inference**, with the same fields as the detail panel's editors. Leave the -fields empty and the request is the plain name and hive; collapsing a section doesn't -withdraw what you typed in it. Fill them and `POST /api/agents` carries them +Step 4 of the **+ agent** dialog has **resource caps** and **inference**, with +the same fields as the detail panel's editors. Leave the +fields empty and the request is the plain name and hive. Fill them and `POST /api/agents` carries them as optional `limits` and `inference` objects, the same shapes the two `PUT` routes take. The controller checks them with the `PUT` rules before it queues anything, so a bad value gets a refusal with its reason and creates no agent. The dialog loads the hive list and the permission list when it opens. If either -load fails, the dialog shows the reason in an error panel above the fields, and +load fails, the dialog shows the reason in an error panel above the step's fields, and the hive picker or the permissions area reads `could not load hives` or `could not load permissions` in place of `loading…`. @@ -550,14 +573,14 @@ stored settings when you leave the fields empty. ### Initial AGENTS.md at creation -A third collapsed section, **instructions**, holds a plain text box for the +Step 2, **instructions**, holds a plain text box for the text the new agent's `AGENTS.md` starts with. Left empty, or holding only whitespace, it sends nothing and the agent starts without the file. Otherwise the text goes as typed in the optional `agents_md` field of `POST /api/agents`. The agent writes it on its first start, and only when it has no `AGENTS.md` yet, so creating an agent again under a name that has one never replaces it. The controller refuses a text over 64 KiB. Its reason shows under the -section, open or not, and the swarm creates no agent. After a successful +box, and the swarm creates no agent. After a successful create the box empties. The [instructions editor](#editing-an-agents-instructions) changes the file later. The [swarm README](README.md#an-agents-agentsmd) has the wire details. diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css index a1960f15..893b9595 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css @@ -1,75 +1,127 @@ -/* — the create-agent form, rendered inside a `Dialog` - from `AgentsPage` (see that component's file-top comment for why). - Input/button chrome comes from the shared `ui/` form kit - (`TextField`/`SelectField`/`Button`); this file only owns this - component's own layout + copy. Column, - not row inside the form itself: with a second field (hive) added, a - row layout put fields of different natural widths on one baseline and - looked misaligned — mara: "make it a col". Reuses the same base16 - slots (../theme.css) every other component draws from. +/* — the create-agent dialog, a stepper rendered inside a + `Dialog` from `AgentsPage`. Input/button chrome comes from the shared `ui/` + form kit; this file owns the layout: a header, the step tabs and a footer + that stay put while only the step's own fields scroll. - This layout dropped an explicit page `max-width` after a few rounds: - first added so bare `Panel` (no width opinion of its own) wouldn't - fill `.shell-body`'s full 60em column behind a narrow form (the - original "weird empty space" complaint), then widened for two panels - side by side — but once both were equal-width flex children that cap - itself became "doesn't fill the space" (mara: "the cards are neither - centered nor fill the space tho"). Removing it lets the row fill - `.shell-body`'s own width like most pages — nothing sits off-center - once nothing is narrower than the row. - - `.create-agent-form-col`/`.create-agent-info-col` are equal-sized - (`flex: 1 1 0` — zero basis so the 1.5em `gap` splits the row evenly, - not each column keeping its natural content width) per mara's "both - cards should be equal sized". `min-width` is the wrap threshold below - which the row goes single-column instead (info card moving under the - form) — no separate media query needed. Inputs fill their whole - column now too ("inputs should fill full card width") — the kit's - `.ui-form-control`/`.ui-form-field` dropped their old 16em cap for - exactly this (../ui/form-field/FormField.css). */ -.create-agent-layout { + `.create-agent-panel` is a column no taller than the dialog (85vh, the + dialog's own cap), and its body hands all that height to the step. */ +.create-agent-panel { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: min(90vw, 44em); + max-height: 85vh; +} +.create-agent-panel > .ui-panel-body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} +.create-agent-wizard { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + gap: 0.75em; +} +.create-agent-steps { display: flex; flex-wrap: wrap; - align-items: flex-start; - gap: 1.5em; + gap: 0.35em; + margin: 0; + padding: 0; + list-style: none; } -.create-agent-form-col, -.create-agent-info-col { - flex: 1 1 0; - min-width: 16em; +.create-agent-step-tab { + padding: 0.3em 0.7em; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--muted); + font: inherit; + font-size: 0.85em; + cursor: pointer; +} +.create-agent-step-tab:disabled { + cursor: default; + opacity: 0.6; +} +.create-agent-step-current { + border-color: var(--purple); + color: var(--fg); +} +/* The one part that scrolls. A floor keeps the dialog from changing height + as the steps change. */ +.create-agent-step { + flex: 1 1 auto; + min-height: 18em; + overflow-y: auto; +} +/* On a short window the floor gives way, so the footer stays on screen. */ +@media (max-height: 45em) { + .create-agent-step { + min-height: 8em; + } +} +.create-agent-fields { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.75em; } .create-agent-intro { - margin: 0 0 1.5em; + margin: 0; color: var(--muted); } -.create-agent-form { +.create-agent-heading { + margin: 0.5em 0 0; + font-size: 1em; +} +.create-agent-footer { + display: flex; + flex: none; + align-items: center; + gap: 0.5em; + padding-top: 0.75em; + border-top: 1px solid var(--border); +} +/* Back on the left, then why next waits, then next (or create) on the right. */ +.create-agent-footer > :last-child { + margin-left: auto; +} +.create-agent-hint { + flex: 1 1 auto; + min-width: 0; + margin-left: 0.25em; + color: var(--muted); + font-size: 0.9em; +} +.create-agent-summary { display: flex; flex-direction: column; - align-items: flex-start; - gap: 0.75em; + gap: 0.35em; + margin: 0; } -/* Optional settings, collapsed so the plain name-and-hive form stays short. */ -.create-agent-section { - align-self: stretch; +.create-agent-summary-row { + display: flex; + flex-wrap: wrap; + gap: 0.25em 1em; } -.create-agent-section > summary { - cursor: pointer; +.create-agent-summary-row dt { + flex: 0 0 9em; color: var(--muted); } -.create-agent-section-body { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.75em; - margin-top: 0.75em; -} -.create-agent-result { - margin-top: 1em; +.create-agent-summary-row dd { + flex: 1 1 12em; + min-width: 0; + margin: 0; + overflow-wrap: anywhere; } .create-agent-result-ok { color: var(--green); } -/* The controller's refusal of the AGENTS.md text, under its section. */ +/* The controller's refusal of the AGENTS.md text, under its box. */ .create-agent-field-error { margin: 0; color: var(--red); diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 4fce32cc..1d0f28ea 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -4,16 +4,13 @@ // guaranteed to exist yet when this gets a response — so this shows // the queued confirmation and links to the Jobs page to watch it // settle, rather than duplicating JobqGraph's per-node polling here. -// Scope matches the originating issue exactly: name field only, no -// forge/deploy options (those aren't wired server-side yet either). // -// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button, -// not a standalone route — the roster it feeds is the natural home for -// the action that populates it, and a distinct top-level nav item next -// to it was one click of indirection for no benefit. Kept as its own -// component (not inlined into AgentsPage) since the two-panel layout -// below is unchanged from its former life as a page: same content, same -// review history, just a different mount point. +// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button, not a +// standalone route: the roster it feeds is the natural home for the action +// that populates it. It is a stepper (see `CreateAgentSteps`): identity, +// instructions, permissions, resources and inference, then a summary that +// says what will be sent and holds **create**. Every step is gone through, in +// order; the header, step tabs and footer stay put while the step scrolls. // // `hive` field added because agent creation had no way to record which // hive an agent runs on. `POST /api/agents` now requires it, so the @@ -62,6 +59,16 @@ import { type ArchetypeList, } from "./CreateAgentArchetype.js"; import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js"; +import { + NAME_PATTERN, + nextStep, + previousStep, + STEP_TITLES, + STEPS, + stepProblem, + summaryRows, + type Step, +} from "./CreateAgentSteps.js"; import "./CreateAgentForm.css"; // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` @@ -89,21 +96,6 @@ type SubmitState = | { status: "done"; nodeId: number } | { status: "error"; problem: ProblemDetails }; -// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) — -// a `pattern` hint only, not a substitute for the server's own -// validation; a name this rejects would fail server-side anyway, so -// catching it before a round-trip is a pure UX win, not a new gate. -// -// Hyphen escaped (`\-`) rather than left trailing in the class: modern -// browsers validate the `pattern` attribute's regex in Unicode-set (`v`) -// mode, which is far stricter about hyphen placement than classic mode — -// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though -// it's valid classic-mode regex (a trailing `-` is unambiguous there). -// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws, -// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console -// exception report on this page. -const NAME_PATTERN = "[a-z0-9\\-]{1,63}"; - export function CreateAgentForm({ onClose, initialName = "", @@ -118,6 +110,7 @@ export function CreateAgentForm({ * floats its own. */ onClose?: () => void; }) { + const [step, setStep] = useState("identity"); const [name, setName] = useState(initialName); const [hive, setHive] = useState(""); // `null` = still loading, `[]` = loaded but empty (a real, if unusual, @@ -220,8 +213,7 @@ export function CreateAgentForm({ const refusal = result.status === "error" ? agentsMdRefusal(result.problem) : null; - async function submit(e: Event) { - e.preventDefault(); + async function submit() { setResult({ status: "submitting" }); try { // A section left empty is left out, so the request is the plain @@ -244,7 +236,10 @@ export function CreateAgentForm({ }), }); if (!r.ok) { - setResult({ status: "error", problem: await readApiError(r) }); + const problem = await readApiError(r); + setResult({ status: "error", problem }); + // The text's own refusal shows beside its box, so go back to it. + if (agentsMdRefusal(problem) !== null) setStep("instructions"); return; } const data = (await r.json()) as CreateAgentResponse; @@ -261,16 +256,52 @@ export function CreateAgentForm({ } } + const problem = stepProblem(step, { + name, + hive, + permissionsReady: catalogue !== null, + inferenceProblem, + }); + const done = result.status === "done"; + const at = STEPS.indexOf(step); + + // Enter goes on to the next step, and creates only from the summary. + function onSubmit(e: Event) { + e.preventDefault(); + if (done) return; + if (step === "summary") void submit(); + else if (problem === null) setStep(nextStep(step)); + } + + function createAnother() { + setResult({ status: "idle" }); + setStep("identity"); + } + return ( -
-
- -

- Create a new agent's swarm-level identity. This only queues the job - — check jobs to watch it settle. Resource - caps, inference and instructions are optional; leave them empty to - set nothing. A name set up before keeps the settings stored for it. -

+ +
+
    + {STEPS.map((s, i) => ( +
  1. + +
  2. + ))} +
+
{hivesError && ( )} - - - - {archetypes && archetypes.archetypes.length > 0 && ( - <> - ({ - value: archetypeKey(a), - label: a.name, - })), - ]} - /> - {picked && ( -

- {picked.description} An agent created from an archetype has - no config repo: its hive builds it from the archetype's - module. -

- )} - - )} - -
- {catalogue ? ( - setSelected((s) => togglePermission(s, v))} + {done && ( +
+

+ Queued as job node #{result.nodeId} —{" "} + watch it in jobs. +

+
+ )} + {!done && step === "identity" && ( +
+

+ A new agent's swarm-level identity: its name, the hive it runs + on, and optionally an archetype that fills in the settings of + the later steps. A name set up before keeps the settings stored + for it. +

+ + + {archetypes && archetypes.archetypes.length > 0 && ( + <> + ({ + value: archetypeKey(a), + label: a.name, + })), + ]} /> - ) : ( - - {catalogueError ? "could not load permissions" : "loading…"} - - )} -
- -
- resource caps -
- -
-
-
- inference -
- -
-
-
- instructions -
- -
-
- {refusal !== null && ( -

{refusal}

- )} - - - {result.status === "done" && ( -

- queued as job node #{result.nodeId} —{" "} - watch it in jobs -

+ {picked && ( +

+ {picked.description} An agent created from an archetype + has no config repo: its hive builds it from the + archetype's module. +

+ )} + + )} +
)} - {result.status === "error" && refusal === null && ( - + {!done && step === "instructions" && ( +
+

+ The text the agent's AGENTS.md starts with. Optional: leave it + empty and the agent starts without the file. +

+ + {refusal !== null && ( +

{refusal}

+ )} +
)} - -
-
- {/* Describes the intended end state (a running agent), not today's - literal behaviour (deploying the container onto the hive isn't - wired up server-side yet — see this file's top comment) — - mara's explicit call on this copy: read as finished, not as a - running commentary on partial implementation. */} - -

- Submitting this queues everything a new agent needs: a swarm-level - identity, a config repo on the forge with the operator added as a - collaborator, and a container running the agent on the hive you pick - above. An agent created from an archetype gets no config repo: its - container runs the archetype's module. Watch it all settle on{" "} - jobs. -

-
-
-
+ {!done && step === "permissions" && ( +
+

+ What the swarm lets the agent do. Leaving the default set ticked + keeps the agent on the default if that changes. +

+ +
+ {catalogue ? ( + + setSelected((s) => togglePermission(s, v)) + } + /> + ) : ( + + {catalogueError + ? "could not load permissions" + : "loading…"} + + )} +
+
+
+ )} + {!done && step === "resources" && ( +
+

+ Optional: leave a box empty to set nothing, and the hive's or + the agent's own value applies. +

+

resource caps

+ +

inference

+ +
+ )} + {!done && step === "summary" && ( +
+
+ {summaryRows({ + name, + hive, + archetype: picked?.name, + permissions: catalogue + ? requestPermissions(selected, catalogue.default) + : undefined, + caps: capsBody(cpu, memory), + inference: draftBody(inference), + agentsMd, + }).map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ {/* Describes the intended end state (a running agent), not + today's literal behaviour: mara's call on this copy was to + read as finished, not as a running commentary on partial + implementation. */} +

what this creates

+

+ Creating queues everything a new agent needs: a swarm-level + identity, a config repo on the forge with the operator added as + a collaborator, and a container running the agent on the hive + above. An agent created from an archetype gets no config repo: + its container runs the archetype's module. Watch it all settle + on jobs. +

+ {result.status === "error" && refusal === null && ( + + )} +
+ )} +
+ + + ); } diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentSteps.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentSteps.ts new file mode 100644 index 00000000..93437f0e --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentSteps.ts @@ -0,0 +1,133 @@ +// The create-agent dialog's steps: which there are, what lets the operator +// leave one, and what the last one says will be sent. Kept out of the +// component so `npm test` can run it. + +export const STEPS = [ + "identity", + "instructions", + "permissions", + "resources", + "summary", +] as const; + +export type Step = (typeof STEPS)[number]; + +export const STEP_TITLES: Record = { + identity: "identity", + instructions: "instructions", + permissions: "permissions", + resources: "resources and inference", + summary: "summary", +}; + +// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`), the +// hint the name box shows; the server's own check stays the authority. +// +// The hyphen is escaped: browsers read a `pattern` attribute in Unicode-set +// (`v`) mode, which refuses a bare trailing hyphen in a class. +export const NAME_PATTERN = "[a-z0-9\\-]{1,63}"; + +export function nameProblem(name: string): string | null { + return new RegExp(`^${NAME_PATTERN}$`).test(name) + ? null + : "the name is 1-63 characters: lowercase letters, digits and hyphens"; +} + +/** The step after `step`, or `step` itself on the last. */ +export function nextStep(step: Step): Step { + return STEPS[Math.min(STEPS.indexOf(step) + 1, STEPS.length - 1)]; +} + +/** The step before `step`, or `step` itself on the first. */ +export function previousStep(step: Step): Step { + return STEPS[Math.max(STEPS.indexOf(step) - 1, 0)]; +} + +/** What the steps need before the one `step` can be left. */ +export interface StepInputs { + name: string; + hive: string; + // The permission list has loaded, so a pick is made against the real set. + permissionsReady: boolean; + // `draftProblem` of the inference boxes. + inferenceProblem: string | null; +} + +/** + * Why the operator cannot go on from `step`, or null when they can. Every + * step must be gone through, so this gates **next**; the last step has + * nothing after it. + */ +export function stepProblem(step: Step, input: StepInputs): string | null { + switch (step) { + case "identity": + return ( + nameProblem(input.name) ?? (input.hive === "" ? "pick a hive" : null) + ); + case "permissions": + return input.permissionsReady ? null : "the permission list is loading"; + case "resources": + return input.inferenceProblem; + default: + return null; + } +} + +/** One line of the summary: a setting and what will be sent for it. */ +export interface SummaryRow { + label: string; + value: string; +} + +export interface SummaryInput { + name: string; + hive: string; + // The picked archetype's name, or undefined for a custom agent. + archetype: string | undefined; + // `requestPermissions`: undefined when the default set is kept. + permissions: string[] | undefined; + // The filled fields only, as `capsBody` and `draftBody` make them. + caps: object; + inference: object; + agentsMd: string; +} + +function settings(values: object): string { + const parts = Object.entries(values).map( + ([key, value]) => + `${key.replace(/_/g, " ")} ${Array.isArray(value) ? value.join(", ") : String(value)}`, + ); + return parts.length === 0 ? "none set" : parts.join("; "); +} + +/** + * What the summary lists: every setting the request carries, and for each + * one left alone, what then applies. + */ +export function summaryRows(s: SummaryInput): SummaryRow[] { + const words = + s.agentsMd.trim() === "" ? 0 : s.agentsMd.trim().split(/\s+/).length; + return [ + { label: "name", value: s.name }, + { label: "hive", value: s.hive }, + { label: "archetype", value: s.archetype ?? "none: a custom agent" }, + { + label: "permissions", + value: + s.permissions === undefined + ? "the default set" + : s.permissions.length === 0 + ? "none" + : s.permissions.join(", "), + }, + { label: "resource caps", value: settings(s.caps) }, + { label: "inference", value: settings(s.inference) }, + { + label: "instructions", + value: + words === 0 + ? "none" + : `AGENTS.md, ${words} ${words === 1 ? "word" : "words"}`, + }, + ]; +} diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css index 37f0a25f..3900cf18 100644 --- a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css +++ b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css @@ -1,10 +1,7 @@ /* — rendered inside a `Dialog` from `AgentsPage`, - one panel wide (no second info column — this form has no "what this - creates" explanation worth a whole sibling card the way - `CreateAgentForm` does, just the one-line note already in the JSX). - Column layout + `.link-matrix-account-result-ok` mirror - `CreateAgentForm.css`'s `.create-agent-form`/`.create-agent-result-ok` - exactly — same shape, kept as its own file rather than sharing that + one panel wide. Column layout + `.link-matrix-account-result-ok` + mirror `CreateAgentForm.css`'s `.create-agent-fields`/`.create-agent-result-ok` + — same shape, kept as its own file rather than sharing that one since "form" here means a different component's layout, not a generic kit primitive (see that file's own top comment on why `ui/form-field` stays the only truly shared piece). */ diff --git a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css index 94906f12..f8ac6a15 100644 --- a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css +++ b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css @@ -4,7 +4,7 @@ whichever container the caller gives it — the kit itself has no opinion on a maximum width; a page that wants one narrower than its own layout caps it at the layout level (`CreateAgentForm.css`'s - `.create-agent-form-col` is the existing example), same reasoning + `.create-agent-panel` is the existing example), same reasoning `Panel` has no width opinion of its own either. `min-height` is a touch-target floor (44px at the default 16px root font — WCAG 2.5.5's minimum), not a visual choice — it's the same on every diff --git a/frontend/packages/swarm-ui/test/create-agent-steps.test.mjs b/frontend/packages/swarm-ui/test/create-agent-steps.test.mjs new file mode 100644 index 00000000..67f2da36 --- /dev/null +++ b/frontend/packages/swarm-ui/test/create-agent-steps.test.mjs @@ -0,0 +1,101 @@ +// The create-agent dialog's steps: order, what gates next, and the summary. +// Run by `npm test` with Node's type stripping. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + nameProblem, + nextStep, + previousStep, + STEPS, + stepProblem, + summaryRows, +} from "../src/pages/CreateAgentSteps.ts"; + +const ready = { + name: "ruth", + hive: "h1", + permissionsReady: true, + inferenceProblem: null, +}; + +test("the steps run in order and stop at both ends", () => { + assert.deepEqual(STEPS, [ + "identity", + "instructions", + "permissions", + "resources", + "summary", + ]); + assert.equal(nextStep("identity"), "instructions"); + assert.equal(nextStep("resources"), "summary"); + assert.equal(nextStep("summary"), "summary"); + assert.equal(previousStep("summary"), "resources"); + assert.equal(previousStep("identity"), "identity"); +}); + +test("a name is 1-63 lowercase letters, digits and hyphens", () => { + assert.equal(nameProblem("ruth-2"), null); + assert.equal(nameProblem("a".repeat(63)), null); + assert.notEqual(nameProblem(""), null); + assert.notEqual(nameProblem("a".repeat(64)), null); + assert.notEqual(nameProblem("Ruth"), null); + assert.notEqual(nameProblem("ru th"), null); +}); + +test("identity needs a valid name and a hive before next", () => { + assert.equal(stepProblem("identity", ready), null); + assert.notEqual(stepProblem("identity", { ...ready, name: "" }), null); + assert.notEqual(stepProblem("identity", { ...ready, name: "No Way" }), null); + assert.equal(stepProblem("identity", { ...ready, hive: "" }), "pick a hive"); +}); + +test("permissions waits for the list, resources for a valid inference", () => { + assert.notEqual( + stepProblem("permissions", { ...ready, permissionsReady: false }), + null, + ); + assert.equal(stepProblem("permissions", ready), null); + assert.equal( + stepProblem("resources", { ...ready, inferenceProblem: "bad model" }), + "bad model", + ); + assert.equal(stepProblem("instructions", { ...ready, name: "" }), null); +}); + +test("the summary lists what is sent and what stays at its default", () => { + const rows = (over) => + Object.fromEntries( + summaryRows({ + name: "ruth", + hive: "h1", + archetype: undefined, + permissions: undefined, + caps: {}, + inference: {}, + agentsMd: "", + ...over, + }).map((r) => [r.label, r.value]), + ); + const plain = rows({}); + assert.equal(plain.name, "ruth"); + assert.equal(plain.archetype, "none: a custom agent"); + assert.equal(plain.permissions, "the default set"); + assert.equal(plain["resource caps"], "none set"); + assert.equal(plain.inference, "none set"); + assert.equal(plain.instructions, "none"); + + const set = rows({ + archetype: "reviewer", + permissions: ["meta", "inbox"], + caps: { cpu_quota: "200%", memory_max: "2G" }, + inference: { model: "opus", subagent_models: ["haiku", "sonnet"] }, + agentsMd: " Be brief.\n", + }); + assert.equal(set.archetype, "reviewer"); + assert.equal(set.permissions, "meta, inbox"); + assert.equal(set["resource caps"], "cpu quota 200%; memory max 2G"); + assert.equal(set.inference, "model opus; subagent models haiku, sonnet"); + assert.equal(set.instructions, "AGENTS.md, 2 words"); + assert.equal(rows({ permissions: [] }).permissions, "none"); + assert.equal(rows({ agentsMd: "x" }).instructions, "AGENTS.md, 1 word"); +});