import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core"; import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { cursorAgent, isDryRun } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; import type { CoderMeta } from "../coder/index.js"; import type { PlannerMeta } from "../planner/index.js"; import { testerPrompt } from "./prompt.js"; export const testerMetaSchema = z.object({ workflowName: z.string().default(""), attempt: z.number().default(1), passed: z.boolean().default(false), dryRunLog: z.string().default(""), reason: z.string().default(""), }); export type TesterMeta = z.infer; export type BuildTesterDeps = { nerveRoot: string; }; function formatSpawnFailure(error: SpawnError): string { if (error.kind === "spawn_failed") { return error.message; } if (error.kind === "timeout") { return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`; } return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`; } function lastMetaForRole(messages: WorkflowMessage[], role: string): M | null { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === role) { return messages[i].meta as M; } } return null; } export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role { return async (start, messages) => { const dry = isDryRun(start); const plannerMeta = lastMetaForRole(messages, "planner"); const coderMeta = lastMetaForRole(messages, "coder"); const attempt = messages.filter((m) => m.role === "tester").length + 1; if (plannerMeta === null || coderMeta === null) { return { content: "tester cannot continue: missing planner/coder output", meta: { workflowName: "", attempt, passed: false, dryRunLog: "", reason: "missing planner/coder output", }, } satisfies RoleResult; } if (!coderMeta.lintPassed || !coderMeta.buildPassed) { return { content: "tester blocked: coder has not passed lint+build", meta: { workflowName: coderMeta.workflowName, attempt, passed: false, dryRunLog: `${coderMeta.lintLog}\n\n${coderMeta.buildLog}`, reason: "coder did not pass lint+build", }, } satisfies RoleResult; } if (dry) { return { content: "PASS — dry-run mode", meta: { workflowName: coderMeta.workflowName, attempt, passed: true, dryRunLog: "[dry-run] tester skipped external checks", reason: "dry-run mode", }, } satisfies RoleResult; } const prompt = testerPrompt({ workflowName: coderMeta.workflowName, plannerSpec: { roles: plannerMeta.roles, flowTransitions: plannerMeta.flowTransitions, validationLoopsDesign: plannerMeta.validationLoopsDesign, externalDeps: plannerMeta.externalDeps, dataFlow: plannerMeta.dataFlow, }, coderOutput: coderMeta.cursorOutput, nerveRoot, }); const run = await cursorAgent({ prompt, mode: "ask", cwd: nerveRoot, env: null, timeoutMs: null, dryRun: false, }); if (!run.ok) { return { content: "tester agent failed", meta: { workflowName: coderMeta.workflowName, attempt, passed: false, dryRunLog: "", reason: `tester agent failed: ${formatSpawnFailure(run.error)}`, }, } satisfies RoleResult; } const text = run.value.trim(); const pass = text.startsWith("PASS|"); const fail = text.startsWith("FAIL|"); if (!pass && !fail) { return { content: "tester format invalid", meta: { workflowName: coderMeta.workflowName, attempt, passed: false, dryRunLog: text, reason: "tester format invalid", }, } satisfies RoleResult; } const parts = text.split("|"); const reason = parts[1] ?? "no reason"; const log = parts.slice(2).join("|").trim(); return { content: `${pass ? "PASS" : "FAIL"} — ${reason}`, meta: { workflowName: coderMeta.workflowName, attempt, passed: pass, dryRunLog: log, reason, }, } satisfies RoleResult; }; }