// Tests for the `human` delivery-graph node (ADR 0005, slice S3): form resolution (specific-else- // generic + gated agent-router), typed-emit binding, and the #289 late-binding surfaces. Pure logic // (no engine), plus a structural + cross-layer guard tying the committed BPMN body / `.form` files to // the completer allowlist and the inbox read-model so the two can't silently diverge. import { test } from "node:test"; import { readFileSync } from "node:fs"; import { assert, assertEquals, assertStringIncludes } from "#test-assert"; import type { DeliveryFact } from "../nano-generated/api-io.d.ts"; import { bindHumanEmits, DELIVERY_HUMAN_ELEMENT, deliveryHumanContextQuestion, deriveHumanCategory, GENERIC_HUMAN_FORM, HUMAN_ACK_FORM, HUMAN_PUBLISH_FORM, humanEmitBind, needsAgentFormRouter, normalizeEmits, renderHumanEmitBrief, resolveHumanForm, } from "./deliveryHuman.ts"; import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts"; import { USER_TASK_KIND_LABELS } from "./userTasks.ts"; const artifact = (name = "resolvedArtifact"): DeliveryFact => ({ name, type: "artifact" }); const version = (name = "publishedVersion"): DeliveryFact => ({ name, type: "version" }); const str = (name: string): DeliveryFact => ({ name, type: "string" }); // ── Form resolution: explicit → category → generic → agent-router ────────────────────────────── test("resolveHumanForm: an explicit formKey always wins, even over a category match", () => { const r = resolveHumanForm({ emits: [artifact()], human: { formKey: "my-bespoke-form" } }); assertEquals(r.source, "explicit"); assertEquals(r.formKey, "my-bespoke-form"); assertEquals(r.category, null); }); test("resolveHumanForm: a blank/whitespace explicit formKey does not count as explicit", () => { const r = resolveHumanForm({ emits: [], human: { formKey: " " } }); assertEquals(r.source, "category"); assertEquals(r.formKey, HUMAN_ACK_FORM); }); test("resolveHumanForm: no emits selects the ack (click-done) category form", () => { const r = resolveHumanForm({ emits: [], human: {} }); assertEquals(r.source, "category"); assertEquals(r.category, "ack"); assertEquals(r.formKey, HUMAN_ACK_FORM); }); test("resolveHumanForm: a single artifact emit selects the publish category form", () => { const r = resolveHumanForm({ emits: [artifact()], human: {} }); assertEquals(r.source, "category"); assertEquals(r.category, "publish"); assertEquals(r.formKey, HUMAN_PUBLISH_FORM); }); test("resolveHumanForm: a single bare-version emit falls through to the generic form", () => { // The publish form captures a pkg@version `resolvedArtifact`, which fails `version` validation in // bindHumanEmits — so a lone `version` must use the generic single-value form, not publish. const r = resolveHumanForm({ emits: [version()], human: {} }); assertEquals(r.source, "generic"); assertEquals(r.formKey, GENERIC_HUMAN_FORM); assertEquals(r.category, null); }); test("resolveHumanForm: a single non-artifact scalar emit falls through to the generic form", () => { const r = resolveHumanForm({ emits: [str("approvalNote")], human: {} }); assertEquals(r.source, "generic"); assertEquals(r.formKey, GENERIC_HUMAN_FORM); assertEquals(r.category, null); }); test("regression: a single version resolves to a form whose capture key binds against `version`", () => { // Guards the class: the resolved form's canonical capture key must produce a value that coerces // against the emitted fact's type. The publish form captures `resolvedArtifact` (pkg@version), // which FAILS `version` coercion — so a lone version must route to the generic `value` form. const emit = version("publishedVersion"); const r = resolveHumanForm({ emits: [emit], human: {} }); assertEquals(r.formKey, GENERIC_HUMAN_FORM); // Generic form's `value` capture binds cleanly against the version fact. assertEquals(bindHumanEmits([emit], { value: "1.4.0" }).errors, []); // Whereas the publish form's `resolvedArtifact` (a pkg@version) would NOT — the mismatch avoided. assert( bindHumanEmits([emit], { resolvedArtifact: "@nanobpm/urban@0.54.0" }).errors.length > 0, "a pkg@version resolvedArtifact must not satisfy a bare-version emit", ); }); test("resolveHumanForm: two-plus heterogeneous emits with no form gate the agent-router (null form)", () => { const node = { emits: [artifact(), str("changelog")], human: {} }; const r = resolveHumanForm(node); assertEquals(r.source, "agent-router"); assertEquals(r.formKey, null); assert(needsAgentFormRouter(node), "needsAgentFormRouter must agree the router fires"); }); test("resolveHumanForm: an explicit form suppresses the agent-router even with many emits", () => { const node = { emits: [artifact(), str("changelog"), version("v")], human: { formKey: "multi" } }; assertEquals(resolveHumanForm(node).source, "explicit"); assert(!needsAgentFormRouter(node), "an explicit form must pre-empt the router"); }); test("deriveHumanCategory: ack for empty, publish for one artifact, null for a lone version/scalar", () => { assertEquals(deriveHumanCategory([]), "ack"); assertEquals(deriveHumanCategory([artifact()]), "publish"); // A lone bare `version` is NOT publish — the publish form captures a pkg@version resolvedArtifact, // which fails `version` coercion; it falls through to the generic single-value form. assertEquals(deriveHumanCategory([version()]), null); assertEquals(deriveHumanCategory([str("x")]), null); assertEquals(deriveHumanCategory([artifact(), version()]), null); }); test("normalizeEmits: drops malformed declarations (non-record, blank name, unknown type)", () => { const emits = [ artifact("good"), { name: "", type: "string" }, { name: "bad", type: "nope" }, "junk", { type: "string" }, ] as unknown as DeliveryFact[]; const out = normalizeEmits({ emits }); assertEquals(out.length, 1); assertEquals(out[0].name, "good"); }); test("normalizeEmits: drops names the S0 validator would reject (dotted/non-identifier, over-long, duplicate)", () => { const emits = [ str("good"), { name: "dotted.name", type: "string" }, // fails FACT_NAME_PATTERN (would make . ambiguous) { name: "1leading", type: "string" }, // leading digit — not a bare identifier { name: "has space", type: "string" }, // whitespace — not an identifier { name: `${"x".repeat(129)}`, type: "string" }, // exceeds FACT_NAME_MAX_LENGTH (128) { name: "good", type: "version" }, // duplicate name — first (string) wins, this is dropped ] as unknown as DeliveryFact[]; const out = normalizeEmits({ emits }); assertEquals(out.length, 1); assertEquals(out[0].name, "good"); assertEquals(out[0].type, "string"); }); test("normalizeEmits: a name at exactly the 128-char cap is kept", () => { const emits = [{ name: "x".repeat(128), type: "string" }] as unknown as DeliveryFact[]; const out = normalizeEmits({ emits }); assertEquals(out.length, 1); }); // ── Typed emit binding ───────────────────────────────────────────────────────────────────────── test("bindHumanEmits: a no-emit node yields no facts and no errors regardless of captured form", () => { const r = bindHumanEmits([], { note: "did it", value: "ignored" }); assertEquals(r.facts.length, 0); assertEquals(r.errors.length, 0); }); test("bindHumanEmits: a single artifact binds from the canonical resolvedArtifact capture key", () => { const r = bindHumanEmits([artifact()], { resolvedArtifact: "@nanobpm/urban@0.54.0", note: "n" }); assertEquals(r.errors, []); assertEquals(r.facts.length, 1); assertEquals(r.facts[0].value, "@nanobpm/urban@0.54.0"); assertEquals(r.facts[0].type, "artifact"); }); test("bindHumanEmits: a single scalar binds from the generic form's `value` capture key", () => { const r = bindHumanEmits([str("approvalNote")], { value: "ship it" }); assertEquals(r.errors, []); assertEquals(r.facts[0].value, "ship it"); }); test("bindHumanEmits: a fact keyed by its own name wins over the canonical fallback", () => { const r = bindHumanEmits([version("publishedVersion")], { publishedVersion: "1.4.0", value: "9.9.9" }); assertEquals(r.facts[0].value, "1.4.0"); }); test("bindHumanEmits: multi-emit binds each fact by its own name (no canonical fallback)", () => { const emits = [artifact("art"), str("changelog")]; const r = bindHumanEmits(emits, { art: "@a/b@1.0.0", changelog: "notes" }); assertEquals(r.errors, []); assertEquals(r.facts.length, 2); }); test("bindHumanEmits: a missing declared fact is a path-qualified error", () => { const r = bindHumanEmits([artifact("art")], { note: "n" }); assertEquals(r.facts.length, 0); assertEquals(r.errors.length, 1); assertStringIncludes(r.errors[0], "emits.art"); }); test("bindHumanEmits: an ill-typed artifact/version/url/number/boolean each errors", () => { assertStringIncludes(bindHumanEmits([artifact("a")], { a: "no-at-sign" }).errors[0], "pkg@version"); assertStringIncludes(bindHumanEmits([version("v")], { v: "not a version" }).errors[0], "version"); assertStringIncludes(bindHumanEmits([{ name: "u", type: "url" }], { u: "not a url" }).errors[0], "URL"); assertStringIncludes(bindHumanEmits([{ name: "n", type: "number" }], { n: "abc" }).errors[0], "number"); assertStringIncludes(bindHumanEmits([{ name: "b", type: "boolean" }], { b: "maybe" }).errors[0], "boolean"); }); test("bindHumanEmits: an artifact with an ill-formed version segment errors", () => { // A well-formed `pkg@version` shape but a version segment that is not a valid version must reject — // the version segment validates the same way a bare `version` fact does (#263, shared VERSION_PATTERN). assertStringIncludes( bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@not-a-version" }).errors[0], "pkg@version", ); assertEquals(bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@not-a-version" }).facts.length, 0); // A valid digit-led (optionally `v`-prefixed) version segment still passes. assertEquals(bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@0.54.0" }).facts[0].value, "@nanobpm/urban@0.54.0"); assertEquals(bindHumanEmits([artifact("a")], { a: "pkg@v2.0.0-rc.1" }).facts[0].value, "pkg@v2.0.0-rc.1"); }); test("bindHumanEmits: number/boolean/url coerce to canonical string serialisations", () => { assertEquals(bindHumanEmits([{ name: "n", type: "number" }], { n: "42" }).facts[0].value, "42"); assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: true }).facts[0].value, "true"); // A string boolean is trimmed before validating (like version/artifact/url), so a generic textfield // capture with surrounding whitespace still binds to the canonical serialisation. assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: " true " }).facts[0].value, "true"); assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: "false " }).facts[0].value, "false"); assertEquals( bindHumanEmits([{ name: "u", type: "url" }], { u: "https://x.test/p" }).facts[0].value, "https://x.test/p", ); // A string fact is trimmed before returning (like version/artifact/url), so the canonical // serialisation does not depend on incidental surrounding whitespace. assertEquals(bindHumanEmits([{ name: "s", type: "string" }], { s: " foo " }).facts[0].value, "foo"); }); // ── Late-binding surfaces (#289) ───────────────────────────────────────────────────────────────── test("humanEmitBind: builds a `.` qualified bind map (empty for a no-emit node)", () => { const { facts } = bindHumanEmits([artifact("resolvedArtifact")], { resolvedArtifact: "@a/b@1.0.0" }); assertEquals(humanEmitBind("manual-publish", facts), { "manual-publish.resolvedArtifact": "@a/b@1.0.0" }); assertEquals(humanEmitBind("x", []), {}); }); test("renderHumanEmitBrief: empty for no facts; pins each name→value otherwise", () => { assertEquals(renderHumanEmitBrief([]), ""); const { facts } = bindHumanEmits([artifact("resolvedArtifact")], { resolvedArtifact: "@nanobpm/urban@0.54.0" }); const brief = renderHumanEmitBrief(facts); assertStringIncludes(brief, "Human-emitted facts"); assertStringIncludes(brief, "`resolvedArtifact` (artifact) → `@nanobpm/urban@0.54.0`"); }); test("renderHumanEmitBrief: neutralises backticks/newlines in a value so the inline-code span can't break or inject", () => { const { facts } = bindHumanEmits([str("note")], { note: "a`b\nc" }); const brief = renderHumanEmitBrief(facts); assertStringIncludes(brief, "`note` (string) → `a'b c`"); assert(!brief.includes("a`b"), "raw backtick must not survive into the inline-code span"); }); // ── Cross-layer / structural guards ────────────────────────────────────────────────────────────── const bpmn = readFileSync("resources/processes/delivery-human.bpmn", "utf8"); const flat = bpmn.replace(/\s+/g, " "); test("BPMN: the human node is a native SLA-bounded userTask backed by the generic form", () => { const task = flat.match( new RegExp(`]*\\bid="${DELIVERY_HUMAN_ELEMENT}"[\\s\\S]*?`), ); assert(task, `${DELIVERY_HUMAN_ELEMENT} must be a `); assertStringIncludes(task![0], "]*\\battachedToRef="${DELIVERY_HUMAN_ELEMENT}"[\\s\\S]*?`), ); assert(boundary, "an SLA boundary timer must be attached to the human task"); assertStringIncludes(boundary![0], "=escalationSlaTimeout", "the SLA reuses the escalation timeout var"); assertStringIncludes(boundary![0], " { // Symmetry with readiness-gate.bpmn's `gateOutcome`: a caller reading process variables must be // able to distinguish a completed human step from one the SLA timer escalated. The userTask sets // "completed" on the normal path; the timeout end event must set "escalated" — otherwise the // escalation path ends with humanOutcome unset and the two outcomes are indistinguishable. assertStringIncludes( flat, '', "the completion path must set humanOutcome=completed", ); const escalated = flat.match( /]*\bid="human-escalated"[\s\S]*?<\/bpmn:endEvent>/, ); assert(escalated, "the SLA-timeout end event human-escalated must exist"); assertStringIncludes( escalated![0], '', "the SLA-timeout path must set humanOutcome=escalated", ); }); test("drift guard: the human element is completer-answerable and surfaces on the inbox", () => { // The canonical completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that // parks on delivery-human-task while the code doesn't accept it would deploy but be unanswerable // (by human OR agent, ADR 0046) — the silent-drift failure this guard closes. assert( ESCALATION_TASK_ELEMENTS.has(DELIVERY_HUMAN_ELEMENT), "ESCALATION_TASK_ELEMENTS must accept the delivery human node", ); // And it must carry an inbox label, or the parked task is invisible on the Tasks cockpit. assert( typeof USER_TASK_KIND_LABELS[DELIVERY_HUMAN_ELEMENT] === "string", "USER_TASK_KIND_LABELS must label the delivery human node", ); }); // ── issue #772: Tasks-inbox "Decision context" for a parked delivery-graph human node ───────────── test("deliveryHumanContextQuestion: derives the node instruction from human_labels (base + __esc twin)", () => { const labels = { "delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban" }; // The parked base task looks up its own id. assertEquals( deliveryHumanContextQuestion(labels, "delivery-human-task__n7"), "Run the manual OTP publish for @nanobpm/urban", ); // The bounded-timeout escalation twin parks on `…__esc`; strip it to find the same stamped label. assertEquals( deliveryHumanContextQuestion(labels, "delivery-human-task__n7__esc"), "Run the manual OTP publish for @nanobpm/urban", ); }); test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => { // A parked human step must never render a blank Decision context — an untracked/absent label still // yields actionable guidance rather than null (which would leave the panel empty, issue #772). assertEquals( deliveryHumanContextQuestion({}, "delivery-human-task__n1"), "A scheduled delivery-graph step is waiting to be completed.", ); assertEquals( deliveryHumanContextQuestion(undefined, "delivery-human-task__n1"), "A scheduled delivery-graph step is waiting to be completed.", ); }); test("deliveryHumanContextQuestion: a non-human node's escalation twin (no stored label) gets the node-NEUTRAL fallback, not a 'human step' claim", () => { // `isDeliveryHumanElement` also matches the `__esc`/`__contract` escalation twins that bounded // `agent`/`wait`/`connector` nodes schedule; those carry NO stored human label. The fallback must // not mislabel them as a "human step" (issue #772 review, comment on service.ts contextFor arm). const humanLabels = { "delivery-human-task__n7": "Run the manual OTP publish" }; assertEquals( deliveryHumanContextQuestion(humanLabels, "delivery-human-task__agent5__esc"), "A scheduled delivery-graph step is waiting to be completed.", ); assertEquals( deliveryHumanContextQuestion(humanLabels, "delivery-human-task__agent5__contract"), "A scheduled delivery-graph step is waiting to be completed.", ); // A real human node's own `__esc` timeout twin still resolves the base label. assertEquals( deliveryHumanContextQuestion(humanLabels, "delivery-human-task__n7__esc"), "Run the manual OTP publish", ); }); test("deliveryHumanContextQuestion: a human node whose id itself ends in __esc resolves its EXACT label, not a sibling's", () => { // Node ids may legitimately end in `__esc` (`deliveryGraph.ts` NODE_ID_PATTERN), so such a node is // stamped under the exact key `delivery-human-task__` (…__esc). The exact lookup must win over // the `__esc`-stripped base, otherwise a sibling `n7` node's label would shadow real node `n7__esc`. const labels = { "delivery-human-task__n7": "Sibling n7 label", "delivery-human-task__n7__esc": "The real n7__esc node label", }; assertEquals( deliveryHumanContextQuestion(labels, "delivery-human-task__n7__esc"), "The real n7__esc node label", ); }); // ── issue #772: the generic form must be static / input-only on the Tasks surface ───────────────── const genericForm = readFileSync("resources/forms/delivery-human-generic.form", "utf8"); test("form-structure guard: delivery-human-generic.form uses node-neutral wording", () => { // This shared form is also attached to the `__esc`/`__contract` escalation tasks that bounded // agent/wait/connector nodes create (`app/deliveryGraphCompiler.ts`), not only scheduled `human` // nodes. Copy that calls the task a "scheduled human step" is inaccurate for the escalation family // and can obscure that the task is an escalation, so the static text must stay node-neutral. assert( !/scheduled human step/i.test(genericForm), "the shared generic form must use node-neutral wording (it also serves escalation tasks)", ); }); test("form-structure guard: delivery-human-generic.form carries no {{…}} tokens", () => { // The Tasks surface (`engineForm`) seeds NO form variables, so any `{{token}}` renders literally and // any data-dependent `conditional` mis-fires. Deploy-time `{{token}}` templating is removed too, so // such a token could never resolve. Assert the surface stays context-free-safe (issue #772). assert(!genericForm.includes("{{"), "the generic delivery-human form must not carry {{…}} tokens"); }); test("form-structure guard: delivery-human-generic.form has no data-dependent conditional and an OPTIONAL value", () => { const parsed = JSON.parse(genericForm) as { components: { key?: string; conditional?: unknown; validate?: { required?: boolean } }[]; }; // No component may gate on form data — those blurbs render contradictorily against empty data. for (const c of parsed.components) { assert(c.conditional === undefined, "no component may carry a data-dependent conditional"); } // `value` must be OPTIONAL — a no-emit ("click done") node completes without entering a value. This // S3 generic surface deploys a single static default form and cannot know per-node whether a fact is // required, so it must not client-require `value`; per-node typed-emit binding is the S4 form- // selection path's job (`bindHumanEmits`), not a client-required field on this generic surface. const value = parsed.components.find((c) => c.key === "value"); assert(value, "the generic form must keep a single `value` field"); assert(value!.validate?.required !== true, "`value` must be optional on this surface"); }); test("form-structure guard: delivery-human-generic.form has no task-variable-dependent readonly input", () => { // The Tasks surface (`engineForm`) seeds NO task-local variables, so a readonly INPUT component // (e.g. the old `prompt` textarea) renders permanently blank — a confusing empty "Now do this". // The instruction reaches the operator through the read-model Decision context column instead, so // the surface must carry no keyed readonly control; a static keyless `text` block may point to it. const parsed = JSON.parse(genericForm) as { components: { type?: string; key?: string; readonly?: boolean }[]; }; for (const c of parsed.components) { assert( !(c.readonly === true && typeof c.key === "string"), `keyed readonly input '${c.key}' renders blank on the variable-free Tasks surface`, ); } assert( !parsed.components.some((c) => c.key === "prompt"), "the never-seeded readonly `prompt` control must not reappear on the Tasks surface", ); });