// End-to-end proof for the PR review-loop escalation → native userTask + form (epic #156, slice // U4, issues #597/#599). Boots this whole Urban app in-process against the WASM engine via // `bootTestApp` and proves the migrated escalation round-trip: // // start convergence-loop → the review agent returns `needs_input` with a question → the loop // parks on the native `wait-answer` userTask (linked to `pr-escalation.form`), NOT the retired // `escalation-answered` message catch → the open escalation is derived from the durable // `escalations` audit row (surfaced on GET /app/api/status as `openEscalation`, with NO // denormalised PR-row pointer) → an operator completes the task through the taskInbox surface // with the typed `{ answer }` → the `record-answer` step retires the escalations row to // `answered` and the loop resumes and the answer reaches the next review round. // // The falsifiable core (mirroring the U0 spine e2e): the WASM engine folds a completed instance's // variables away, so "resumes WITH the typed answer" is proven by capturing `job.variables.answer` // on the review agent's SECOND activation — an empty/wrong completion would surface a different // value. The `senior:pr-review` task is an `externalTaskType` (no app worker), so the test // registers a stateful stub for it: needs_input first, converged (capturing the answer) second. // // Network isolation mirrors the sibling convergence e2e: the app's GitHub transport is forced to // `token` mode with no token, so any best-effort GitHub read short-circuits instead of reaching out. // // Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`). import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { after, before, describe, test } from "node:test"; import { fileURLToPath } from "node:url"; import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit"; import { pollUserTasks } from "../app/service.ts"; const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u4-")); const GITHUB_ENV_OVERRIDES: Record = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "", }; const savedEnv = new Map(); const HARNESS_ENV = { NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`, } as const; interface InboxTask { userTaskKey: string; elementId?: string; variables?: Record; } interface StatusBody { prs: Array<{ prKey: string; status: string; openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null; }>; } interface TakenFlow { from: string; to: string; } function takenFlows(app: TestApp): string[] { const snapshot = app.snapshot(); const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : []; return flows .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f) .map((f) => `${f.from}->${f.to}`); } describe("nano-workforce PR review-loop escalation (U4 userTask)", () => { let app: TestApp; // The review agent stub's captured state across activations. let reviewCalls = 0; let capturedAnswer: unknown; before(async () => { for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) { savedEnv.set(k, process.env[k]); process.env[k] = v; } app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV }); // `senior:pr-review` is an externalTaskType (no app worker), so register a stateful stub: // round 1 escalates (needs_input + question); round 2 captures the resume `answer` and converges. await app.engine.registerWorker("senior:pr-review", (job) => { reviewCalls += 1; if (reviewCalls === 1) { return { status: "needs_input", summary: "need a human decision", question: "Which retry cap?" }; } capturedAnswer = (job.variables as Record).answer; return { status: "converged", summary: "resolved after the human answer" }; }); // `senior:scope-classify` is likewise an externalTaskType (no app worker): the converged round // routes through it before finalizing. Stub it as "scope delivered" so the happy path reaches // persist-converged rather than parking on an unserviced agent job. await app.engine.registerWorker("senior:scope-classify", () => { return { scopeBlocked: false, scopeBlockReason: "" }; }); // The converged round also runs the deterministic `pr.converge-gate` app worker, which FAILS // CLOSED without a live GitHub transport (sealed here). This e2e isolates the review-escalation // round-trip — the gate is exercised by `app/convergeGate.test.ts` + the worker integration // test — so stub it as "converged cleanly" so the resumed round finalizes rather than parking // on a second, converge-gate escalation. (Before engine-wasm 0.7.2, a blocked gate's escalation // *question* silently blanked through ioMapping and was suppressed as a non-escalation, hiding // this second escalation; the IO_MAPPING_ERROR fix now renders it, so it must be stubbed out.) await app.engine.registerWorker("pr.converge-gate", () => { return { convergeBlocked: false, convergeBlockReason: "" }; }); }); after(async () => { await app?.stop(); for (const [k, v] of savedEnv) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } rmSync(DB_DIR, { recursive: true, force: true }); }); test("a review-loop escalation parks on a userTask; completing it with {answer} resumes the loop", async () => { const api = app.api; assert.ok(api, "the OpenAPI driver is available"); const prKey = "acme/widgets#77"; const started = await api.call<{ prKey: string }>("startConvergenceLoop", { body: { pr: prKey, convergeOnly: true }, }); assert.equal(started.status, 202, "start returns 202 Accepted"); const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>( "pull_requests", "pr_key", ); const row = await prs.findOne({ pr_key: prKey }); assert.ok(row?.process_key, "the PR row carries the engine process-instance key"); const processInstanceKey = row!.process_key!; // Drain the first review round: needs_input routes through persist-escalation (which sets the // PR `escalated` and returns the `question`) and parks on the native `wait-answer` userTask. await app.settle(); assert.equal(reviewCalls, 1, "the review agent ran exactly once before parking"); // The escalation is a native userTask — visible through the taskInbox surface, NOT a message wait. const listed = await app.callRoute({ method: "GET", path: "/tasks/api/tasks", query: { processInstanceKey }, }); assert.equal(listed.status, 200, "the taskInbox surface serves the task list"); assert.equal(listed.body.length, 1, "exactly one escalation userTask is open"); const task = listed.body[0]; assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask"); assert.ok(task.userTaskKey, "the task carries a completable userTaskKey"); // The open escalation is DERIVED from the `user_tasks` read model on the status endpoint — no // denormalised `open_escalation_*` pointer is written or read. That read model is projected by // `pollUserTasks` (part of the imperative main.ts poll loop the testkit does not run), so drive it // explicitly (mirrors feature-run.e2e.ts) to project the parked `wait-answer` task first. await pollUserTasks(app.db, app.engine); const status = await app.callRoute({ method: "GET", path: "/app/api/status" }); assert.equal(status.status, 200, "the status endpoint responds"); const statusRow = status.body.prs.find((p) => p.prKey === prKey); assert.ok(statusRow, "the escalated PR is listed as active"); assert.equal(statusRow?.status, "escalated", "the PR reads as escalated"); assert.equal( statusRow?.openEscalation?.summary, "Which retry cap?", "the open escalation question is derived from the user_tasks read model", ); // Complete the escalation through the taskInbox completion route with the typed `answer`. const answer = "Cap the retries at 5 and proceed."; const completed = await app.callRoute<{ ok: boolean }>({ method: "POST", path: "/tasks/api/complete", body: JSON.stringify({ userTaskKey: task.userTaskKey, variables: { answer } }), }); assert.equal(completed.status, 200, "the completion route accepts the typed submission"); assert.equal(completed.body.ok, true, "the userTask was completed"); // The typed answer resumed the loop back into the review round: the token took // wait-answer → record-answer (which retires the escalations row) → capture-head → review-round // (the round-entry head is re-captured before each review round, #786), and the review agent saw // exactly the submitted answer. An empty or wrong completion would surface a different // `capturedAnswer` — this is the falsifiable core. await app.settle(); const flows = takenFlows(app); assert.ok( flows.includes("wait-answer->record-answer") && flows.includes("record-answer->capture-head"), `the answer resumed the loop through record-answer back to the review round (flows: ${flows.join(", ")})`, ); assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer"); assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round"); // The escalations row was retired to `answered` and the completed task's `user_tasks` row // reconciles away, so no open escalation lingers on the status endpoint once answered + resumed. // Re-run the projection (as the durable poller would) so the closed task's read-model row is // deleted before re-reading /status. await pollUserTasks(app.db, app.engine); const afterStatus = await app.callRoute({ method: "GET", path: "/app/api/status" }); const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey); if (afterRow) { assert.equal(afterRow.openEscalation, null, "no open escalation lingers after the answer"); } }); });