From 1a685583bd51e4647f97ea69a9fdc20413b84f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 7 May 2026 13:42:01 +0000 Subject: [PATCH] feat: tester role + develop workflow template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New workflow-role-tester: runs tests/build/lint, reports pass/fail - Committer: removed push, only creates branch and commits - New workflow-template-develop: planner → coder ⟲ → reviewer ⟲ → tester → committer - 173 tests passing Fixes #58 --- .../workflow-role-committer/src/committer.ts | 4 +- packages/workflow-role-tester/package.json | 15 + packages/workflow-role-tester/src/index.ts | 1 + packages/workflow-role-tester/src/tester.ts | 27 ++ packages/workflow-role-tester/tsconfig.json | 10 + .../__tests__/develop-template.test.ts | 260 ++++++++++++++++++ .../workflow-template-develop/package.json | 19 ++ .../src/descriptor.ts | 12 + .../workflow-template-develop/src/index.ts | 60 ++++ .../src/moderator.ts | 89 ++++++ .../workflow-template-develop/src/roles.ts | 29 ++ .../workflow-template-develop/tsconfig.json | 17 ++ tsconfig.json | 4 +- 13 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 packages/workflow-role-tester/package.json create mode 100644 packages/workflow-role-tester/src/index.ts create mode 100644 packages/workflow-role-tester/src/tester.ts create mode 100644 packages/workflow-role-tester/tsconfig.json create mode 100644 packages/workflow-template-develop/__tests__/develop-template.test.ts create mode 100644 packages/workflow-template-develop/package.json create mode 100644 packages/workflow-template-develop/src/descriptor.ts create mode 100644 packages/workflow-template-develop/src/index.ts create mode 100644 packages/workflow-template-develop/src/moderator.ts create mode 100644 packages/workflow-template-develop/src/roles.ts create mode 100644 packages/workflow-template-develop/tsconfig.json diff --git a/packages/workflow-role-committer/src/committer.ts b/packages/workflow-role-committer/src/committer.ts index 061fa9e..62915b2 100644 --- a/packages/workflow-role-committer/src/committer.ts +++ b/packages/workflow-role-committer/src/committer.ts @@ -21,12 +21,12 @@ export const committerMetaSchema = z.discriminatedUnion("status", [ export type CommitterMeta = z.infer; -const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push. +const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes. Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable. Do not attempt to fix failures yourself.`; export const committerRole: RoleDefinition = { - description: "Creates branch, commits, and pushes when review passes.", + description: "Creates a branch and commits changes.", systemPrompt: COMMITTER_SYSTEM, extractPrompt: "Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.", diff --git a/packages/workflow-role-tester/package.json b/packages/workflow-role-tester/package.json new file mode 100644 index 0000000..5180905 --- /dev/null +++ b/packages/workflow-role-tester/package.json @@ -0,0 +1,15 @@ +{ + "name": "@uncaged/workflow-role-tester", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "echo 'TODO'", + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow": "workspace:*", + "zod": "^4.0.0" + } +} diff --git a/packages/workflow-role-tester/src/index.ts b/packages/workflow-role-tester/src/index.ts new file mode 100644 index 0000000..3e656b3 --- /dev/null +++ b/packages/workflow-role-tester/src/index.ts @@ -0,0 +1 @@ +export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js"; diff --git a/packages/workflow-role-tester/src/tester.ts b/packages/workflow-role-tester/src/tester.ts new file mode 100644 index 0000000..82abd34 --- /dev/null +++ b/packages/workflow-role-tester/src/tester.ts @@ -0,0 +1,27 @@ +import type { RoleDefinition } from "@uncaged/workflow"; +import * as z from "zod/v4"; + +export const testerMetaSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("passed"), + details: z.string(), + }), + z.object({ + status: z.literal("failed"), + details: z.string(), + }), +]); + +export type TesterMeta = z.infer; + +const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.`; + +export const testerRole: RoleDefinition = { + description: "Runs test, build, and lint commands and reports pass or fail with details.", + systemPrompt: TESTER_SYSTEM, + extractPrompt: + "Extract the verification result: passed with summary details, or failed with details of what broke.", + schema: testerMetaSchema, + extractRefs: null, + extractMode: "single", +}; diff --git a/packages/workflow-role-tester/tsconfig.json b/packages/workflow-role-tester/tsconfig.json new file mode 100644 index 0000000..2816fef --- /dev/null +++ b/packages/workflow-role-tester/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../workflow" }] +} diff --git a/packages/workflow-template-develop/__tests__/develop-template.test.ts b/packages/workflow-template-develop/__tests__/develop-template.test.ts new file mode 100644 index 0000000..4b40329 --- /dev/null +++ b/packages/workflow-template-develop/__tests__/develop-template.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test"; +import { + END, + type ModeratorContext, + type RoleStep, + START, + validateWorkflowDescriptor, +} from "@uncaged/workflow"; + +import type { CommitterMeta } from "@uncaged/workflow-role-committer"; +import type { PlannerMeta } from "@uncaged/workflow-role-planner"; + +import { buildDevelopDescriptor } from "../src/descriptor.js"; +import { developModerator } from "../src/index.js"; +import type { DevelopMeta } from "../src/roles.js"; + +const DEFAULT_PHASES: PlannerMeta["phases"] = [ + { + hash: "4KNMR2PX", + title: "Do the work", + }, +]; + +function makeStart(maxRounds: number): ModeratorContext["start"] { + return { + role: START, + content: "Implement the feature", + meta: { maxRounds }, + timestamp: 0, + }; +} + +function makeCtx( + maxRounds: number, + steps: ModeratorContext["steps"], +): ModeratorContext { + return { + threadId: "01TEST000000000000000000TR", + depth: 0, + start: makeStart(maxRounds), + steps, + }; +} + +function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep { + return { + role: "planner", + contentHash: "STUBHASHPLANNER001", + meta: { phases }, + refs: phases.map((p) => p.hash), + timestamp: 1, + }; +} + +function coderStep(completedPhase = "4KNMR2PX"): RoleStep { + return { + role: "coder", + contentHash: "STUBHASHCODER00001", + meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" }, + refs: [completedPhase], + timestamp: 2, + }; +} + +function reviewerStep(approved: boolean): RoleStep { + return { + role: "reviewer", + contentHash: "STUBHASHREVIEWER01", + meta: approved + ? { status: "approved" as const } + : { status: "rejected" as const, issues: ["needs fix"] }, + refs: [], + timestamp: 3, + }; +} + +function testerStep(passed: boolean): RoleStep { + return { + role: "tester", + contentHash: "STUBHASHTESTER01", + meta: passed + ? { status: "passed" as const, details: "all checks passed" } + : { status: "failed" as const, details: "lint failed" }, + refs: [], + timestamp: 4, + }; +} + +function committerStep(meta: CommitterMeta): RoleStep { + return { + role: "committer", + contentHash: "STUBHASHCOMMITTER1", + meta, + refs: [], + timestamp: 5, + }; +} + +describe("developModerator", () => { + test("routes initial → planner → coder → reviewer → tester → committer → END", () => { + expect(developModerator(makeCtx(20, []))).toBe("planner"); + expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder"); + expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer"); + expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe( + "tester", + ); + expect( + developModerator( + makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]), + ), + ).toBe("committer"); + expect( + developModerator( + makeCtx(20, [ + plannerStep(), + coderStep(), + reviewerStep(true), + testerStep(true), + committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }), + ]), + ), + ).toBe(END); + }); + + test("reviewer rejects → coder retry when budget allows", () => { + const steps: ModeratorContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(false), + ]; + expect(developModerator(makeCtx(20, steps))).toBe("coder"); + }); + + test("reviewer rejects → END when max rounds exhausted", () => { + const steps: ModeratorContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(false), + ]; + expect(developModerator(makeCtx(4, steps))).toBe(END); + }); + + test("tester failed → coder retry when budget allows", () => { + const steps: ModeratorContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(true), + testerStep(false), + ]; + expect(developModerator(makeCtx(20, steps))).toBe("coder"); + }); + + test("tester failed → END when max rounds exhausted", () => { + const steps: ModeratorContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(true), + testerStep(false), + ]; + expect(developModerator(makeCtx(5, steps))).toBe(END); + }); + + test("multiple planner phases → coder until all complete, then reviewer", () => { + const phases: PlannerMeta["phases"] = [ + { hash: "AA000001", title: "first phase" }, + { hash: "AA000002", title: "second phase" }, + ]; + expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder"); + expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe( + "coder", + ); + expect( + developModerator( + makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]), + ), + ).toBe("reviewer"); + }); + + test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => { + const phases: PlannerMeta["phases"] = [ + { hash: "BB000001", title: "setup branch" }, + { hash: "BB000002", title: "write tests" }, + { hash: "BB000003", title: "verify" }, + { hash: "BB000004", title: "polish" }, + ]; + expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe( + "reviewer", + ); + }); + + test("unrecognised completedPhase hash → coder retry when budget allows", () => { + const phases: PlannerMeta["phases"] = [ + { hash: "CC000001", title: "first phase" }, + { hash: "CC000002", title: "second phase" }, + ]; + expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe( + "coder", + ); + }); + + test("incomplete phases → END when max rounds exhausted", () => { + const phases: PlannerMeta["phases"] = [ + { hash: "DD000001", title: "first phase" }, + { hash: "DD000002", title: "second phase" }, + ]; + const steps: ModeratorContext["steps"] = [ + plannerStep(phases), + coderStep("DD000001"), + ]; + expect(developModerator(makeCtx(3, steps))).toBe(END); + }); + + test("committer → END for any committer meta status", () => { + const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" }); + const recoverable = committerStep({ + status: "recoverable", + error: "merge conflict", + logRef: null, + }); + const unrecoverable = committerStep({ + status: "unrecoverable", + error: "repo missing", + logRef: "log1", + }); + const base: ModeratorContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(true), + testerStep(true), + ]; + expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END); + expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END); + expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END); + }); +}); + +describe("buildDevelopDescriptor", () => { + test("lists all roles with schemas that validate", () => { + const descriptor = buildDevelopDescriptor(); + 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", + "tester", + ]); + for (const key of ["planner", "coder", "reviewer", "tester", "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); + } + }); +}); diff --git a/packages/workflow-template-develop/package.json b/packages/workflow-template-develop/package.json new file mode 100644 index 0000000..2447146 --- /dev/null +++ b/packages/workflow-template-develop/package.json @@ -0,0 +1,19 @@ +{ + "name": "@uncaged/workflow-template-develop", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "echo 'TODO'", + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow": "workspace:*", + "@uncaged/workflow-role-coder": "workspace:*", + "@uncaged/workflow-role-committer": "workspace:*", + "@uncaged/workflow-role-planner": "workspace:*", + "@uncaged/workflow-role-reviewer": "workspace:*", + "@uncaged/workflow-role-tester": "workspace:*" + } +} diff --git a/packages/workflow-template-develop/src/descriptor.ts b/packages/workflow-template-develop/src/descriptor.ts new file mode 100644 index 0000000..1dc4057 --- /dev/null +++ b/packages/workflow-template-develop/src/descriptor.ts @@ -0,0 +1,12 @@ +import { buildDescriptor } from "@uncaged/workflow"; + +import { developModerator } from "./moderator.js"; +import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js"; + +export function buildDevelopDescriptor() { + return buildDescriptor({ + description: DEVELOP_WORKFLOW_DESCRIPTION, + roles: developRoles, + moderator: developModerator, + }); +} diff --git a/packages/workflow-template-develop/src/index.ts b/packages/workflow-template-develop/src/index.ts new file mode 100644 index 0000000..f005c5e --- /dev/null +++ b/packages/workflow-template-develop/src/index.ts @@ -0,0 +1,60 @@ +import { + type AgentBinding, + createWorkflow, + type ExtractFn, + type LlmProvider, + type WorkflowDefinition, + type WorkflowFn, +} from "@uncaged/workflow"; + +import { developModerator } from "./moderator.js"; +import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js"; + +export { + type CoderMeta, + coderMetaSchema, + coderRole, +} from "@uncaged/workflow-role-coder"; +export { + type CommitterMeta, + committerMetaSchema, + committerRole, +} from "@uncaged/workflow-role-committer"; +export { + type PlannerMeta, + phaseSchema, + plannerMetaSchema, + plannerRole, +} from "@uncaged/workflow-role-planner"; +export { + type ReviewerMeta, + reviewerMetaSchema, + reviewerRole, +} from "@uncaged/workflow-role-reviewer"; +export { + type TesterMeta, + testerMetaSchema, + testerRole, +} from "@uncaged/workflow-role-tester"; +export { buildDevelopDescriptor } from "./descriptor.js"; +export { developModerator } from "./moderator.js"; +export { + DEVELOP_WORKFLOW_DESCRIPTION, + type DevelopMeta, + type DevelopRoles, + developRoles, +} from "./roles.js"; + +export const developWorkflowDefinition: WorkflowDefinition = { + description: DEVELOP_WORKFLOW_DESCRIPTION, + roles: developRoles, + moderator: developModerator, +}; + +export function createDevelopRun( + binding: AgentBinding, + extract: ExtractFn, + llmProvider: LlmProvider | null, +): WorkflowFn { + return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider); +} diff --git a/packages/workflow-template-develop/src/moderator.ts b/packages/workflow-template-develop/src/moderator.ts new file mode 100644 index 0000000..69205cb --- /dev/null +++ b/packages/workflow-template-develop/src/moderator.ts @@ -0,0 +1,89 @@ +import type { Moderator, ModeratorContext } from "@uncaged/workflow"; +import { END } from "@uncaged/workflow"; + +import type { DevelopMeta } from "./roles.js"; + +function coderFinishedAllPlannedPhases( + phases: ReadonlyArray<{ hash: string }>, + coderCompletedPhases: ReadonlyArray, +): boolean { + if (phases.length === 0) { + return true; + } + const plannedHashes = new Set(phases.map((p) => p.hash)); + const lastHash = phases[phases.length - 1].hash; + const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h))); + if (phases.every((p) => explicit.has(p.hash))) { + return true; + } + if (coderCompletedPhases.some((h) => h === lastHash)) { + return true; + } + return false; +} + +function nextAfterCoder( + ctx: ModeratorContext, + maxRounds: number, +): (keyof DevelopMeta & string) | typeof END { + const plannerStep = ctx.steps.find((s) => s.role === "planner"); + if (plannerStep === undefined) { + return "reviewer"; + } + const phases = plannerStep.meta.phases; + const coderCompletedPhases = ctx.steps + .filter((s) => s.role === "coder") + .map((s) => s.meta.completedPhase); + const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases); + if (allDone) { + return "reviewer"; + } + if (ctx.steps.length < maxRounds - 1) { + return "coder"; + } + return END; +} + +export const developModerator: Moderator = (ctx) => { + const maxRounds = ctx.start.meta.maxRounds; + + if (ctx.steps.length === 0) { + return "planner"; + } + + const last = ctx.steps[ctx.steps.length - 1]; + + if (last.role === "planner") { + return "coder"; + } + + if (last.role === "coder") { + return nextAfterCoder(ctx, maxRounds); + } + + if (last.role === "reviewer") { + if (last.meta.status === "approved") { + return "tester"; + } + if (ctx.steps.length < maxRounds - 1) { + return "coder"; + } + return END; + } + + if (last.role === "tester") { + if (last.meta.status === "passed") { + return "committer"; + } + if (ctx.steps.length < maxRounds - 1) { + return "coder"; + } + return END; + } + + if (last.role === "committer") { + return END; + } + + return END; +}; diff --git a/packages/workflow-template-develop/src/roles.ts b/packages/workflow-template-develop/src/roles.ts new file mode 100644 index 0000000..197f363 --- /dev/null +++ b/packages/workflow-template-develop/src/roles.ts @@ -0,0 +1,29 @@ +import type { RoleDefinition } from "@uncaged/workflow"; +import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder"; +import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer"; +import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner"; +import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer"; +import { type TesterMeta, testerRole } from "@uncaged/workflow-role-tester"; + +export const DEVELOP_WORKFLOW_DESCRIPTION = + "Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer)."; + +export type DevelopMeta = { + planner: PlannerMeta; + coder: CoderMeta; + reviewer: ReviewerMeta; + tester: TesterMeta; + committer: CommitterMeta; +}; + +export type DevelopRoles = { + [K in keyof DevelopMeta]: RoleDefinition; +}; + +export const developRoles: DevelopRoles = { + planner: plannerRole, + coder: coderRole, + reviewer: reviewerRole, + tester: testerRole, + committer: committerRole, +}; diff --git a/packages/workflow-template-develop/tsconfig.json b/packages/workflow-template-develop/tsconfig.json new file mode 100644 index 0000000..bc7bafc --- /dev/null +++ b/packages/workflow-template-develop/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../workflow" }, + { "path": "../workflow-role-coder" }, + { "path": "../workflow-role-committer" }, + { "path": "../workflow-role-planner" }, + { "path": "../workflow-role-reviewer" }, + { "path": "../workflow-role-tester" } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 1f3a873..463fc15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,10 +23,12 @@ { "path": "packages/workflow-role-coder" }, { "path": "packages/workflow-role-planner" }, { "path": "packages/workflow-role-reviewer" }, + { "path": "packages/workflow-role-tester" }, { "path": "packages/workflow-agent-cursor" }, { "path": "packages/workflow-agent-hermes" }, { "path": "packages/workflow-util-agent" }, { "path": "packages/cli-workflow" }, - { "path": "packages/workflow-template-solve-issue" } + { "path": "packages/workflow-template-solve-issue" }, + { "path": "packages/workflow-template-develop" } ] }