import { describe, expect, test } from "bun:test"; import { END, type RoleStep, START, type ThreadContext, validateWorkflowDescriptor, } from "@uncaged/workflow"; import { buildSolveIssueDescriptor } from "../src/descriptor.js"; import { solveIssueModerator } from "../src/moderator.js"; import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js"; function makeStart(maxRounds: number): ThreadContext["start"] { return { role: START, content: "Fix the flaky login test", meta: { maxRounds }, timestamp: 0, }; } function makeCtx( maxRounds: number, steps: ThreadContext["steps"], ): ThreadContext { return { start: makeStart(maxRounds), steps, }; } function plannerStep(): RoleStep { return { role: "planner", content: "plan", meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" }, timestamp: 1, }; } function coderStep(): RoleStep { return { role: "coder", content: "code", meta: { filesChanged: ["a.ts"], summary: "fixed" }, timestamp: 2, }; } function reviewerStep(approved: boolean): RoleStep { return { role: "reviewer", content: "rev", meta: { approved }, timestamp: 3, }; } function committerStep(): RoleStep { return { role: "committer", content: "commit", meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" }, timestamp: 4, }; } describe("solveIssueModerator", () => { test("routes planner → coder → reviewer → committer → END", () => { expect(solveIssueModerator(makeCtx(20, []))).toBe("planner"); expect(solveIssueModerator(makeCtx(20, [plannerStep()]))).toBe("coder"); expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer"); expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe( "committer", ); expect( solveIssueModerator( makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]), ), ).toBe(END); }); test("reviewer rejects → coder retry when budget allows", () => { const steps: ThreadContext["steps"] = [ plannerStep(), coderStep(), reviewerStep(false), ]; expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder"); }); test("reviewer rejects → END when max rounds exhausted", () => { const steps: ThreadContext["steps"] = [ plannerStep(), coderStep(), reviewerStep(false), ]; expect(solveIssueModerator(makeCtx(4, steps))).toBe(END); }); }); describe("createSolveIssueRoles", () => { test("returns all four role callables", async () => { const agent = async () => '{"plan":"x","files":[],"approach":"y"}'; const roles = createSolveIssueRoles({ agent, workdir: "/tmp/repo", extract: null, }); expect(typeof roles.planner).toBe("function"); expect(typeof roles.coder).toBe("function"); expect(typeof roles.reviewer).toBe("function"); expect(typeof roles.committer).toBe("function"); const ctx = makeCtx(10, []); const plannerOut = await roles.planner(ctx as unknown as ThreadContext); expect(plannerOut.meta.plan).toBe(""); expect(Array.isArray(plannerOut.meta.files)).toBe(true); }); }); describe("buildSolveIssueDescriptor", () => { test("lists all roles with schemas that validate", () => { const descriptor = buildSolveIssueDescriptor(); const validated = validateWorkflowDescriptor(descriptor); expect(validated.ok).toBe(true); if (!validated.ok) { throw new Error(validated.error); } expect(Object.keys(validated.value.roles).sort()).toEqual([ "coder", "committer", "planner", "reviewer", ]); for (const key of ["planner", "coder", "reviewer", "committer"] as const) { const role = validated.value.roles[key]; expect(role).toBeDefined(); expect(typeof role.schema).toBe("object"); expect(role.schema).not.toBeNull(); expect(Array.isArray(role.schema)).toBe(false); } }); });