|
|
|
@@ -0,0 +1,284 @@
|
|
|
|
|
import { describe, expect, test } from "bun:test";
|
|
|
|
|
|
|
|
|
|
import type { ModeratorRule } from "../src/jsonata-moderator.js";
|
|
|
|
|
import { evaluateModerator } from "../src/jsonata-moderator.js";
|
|
|
|
|
import type { ModeratorContext, StartStep } from "../src/types.js";
|
|
|
|
|
import { END, START } from "../src/types.js";
|
|
|
|
|
|
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const BASE_START: StartStep = {
|
|
|
|
|
role: START,
|
|
|
|
|
content: "test task",
|
|
|
|
|
meta: {},
|
|
|
|
|
timestamp: 1000,
|
|
|
|
|
parentState: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function makeCtx(
|
|
|
|
|
steps: Array<{ role: string; meta: Record<string, unknown> }>,
|
|
|
|
|
overrides: Partial<ModeratorContext> = {},
|
|
|
|
|
): ModeratorContext {
|
|
|
|
|
return {
|
|
|
|
|
threadId: "01JTESTTHREADID000",
|
|
|
|
|
depth: 0,
|
|
|
|
|
bundleHash: "TESTHASH00001",
|
|
|
|
|
start: BASE_START,
|
|
|
|
|
steps: steps.map((s, i) => ({
|
|
|
|
|
...s,
|
|
|
|
|
contentHash: `hash-${i}`,
|
|
|
|
|
refs: [],
|
|
|
|
|
timestamp: 2000 + i,
|
|
|
|
|
})) as ModeratorContext["steps"],
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Step 1: FALLBACK rule (when=null) always matches ────────────────
|
|
|
|
|
|
|
|
|
|
describe("Step 1: FALLBACK rule", () => {
|
|
|
|
|
test("when=null always matches → returns rule.to", async () => {
|
|
|
|
|
const rules: ModeratorRule[] = [{ from: START, to: "planner", when: null }];
|
|
|
|
|
const result = await evaluateModerator(rules, makeCtx([]));
|
|
|
|
|
expect(result).toBe("planner");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 2: No matching rules → __end__ ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("Step 2: no matching rules", () => {
|
|
|
|
|
test("empty steps, no rule for __start__ → __end__", async () => {
|
|
|
|
|
const rules: ModeratorRule[] = [{ from: "planner", to: "coder", when: null }];
|
|
|
|
|
const result = await evaluateModerator(rules, makeCtx([]));
|
|
|
|
|
expect(result).toBe(END);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("current state has no matching from rules → __end__", async () => {
|
|
|
|
|
const rules: ModeratorRule[] = [{ from: START, to: "planner", when: null }];
|
|
|
|
|
const result = await evaluateModerator(rules, makeCtx([{ role: "planner", meta: {} }]));
|
|
|
|
|
expect(result).toBe(END);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 3: JSONata condition — truthy → match ────────────────────
|
|
|
|
|
|
|
|
|
|
describe("Step 3: JSONata condition evaluation", () => {
|
|
|
|
|
test("truthy expression matches and returns rule.to", async () => {
|
|
|
|
|
const rules: ModeratorRule[] = [
|
|
|
|
|
{
|
|
|
|
|
from: "planner",
|
|
|
|
|
to: END,
|
|
|
|
|
when: "steps[role='planner'].meta.status = 'aborted'",
|
|
|
|
|
},
|
|
|
|
|
{ from: "planner", to: "coder", when: null },
|
|
|
|
|
];
|
|
|
|
|
const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe(END);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("falsy expression skips to next rule (FALLBACK)", async () => {
|
|
|
|
|
const rules: ModeratorRule[] = [
|
|
|
|
|
{
|
|
|
|
|
from: "planner",
|
|
|
|
|
to: END,
|
|
|
|
|
when: "steps[role='planner'].meta.status = 'aborted'",
|
|
|
|
|
},
|
|
|
|
|
{ from: "planner", to: "coder", when: null },
|
|
|
|
|
];
|
|
|
|
|
const ctx = makeCtx([{ role: "planner", meta: { status: "planned" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 4: plannerAborted condition ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("Step 4: plannerAborted", () => {
|
|
|
|
|
const plannerAbortedRules: ModeratorRule[] = [
|
|
|
|
|
{
|
|
|
|
|
from: "planner",
|
|
|
|
|
to: END,
|
|
|
|
|
when: "steps[role='planner'].meta.status = 'aborted'",
|
|
|
|
|
},
|
|
|
|
|
{ from: "planner", to: "coder", when: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test("planner aborted → __end__", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]);
|
|
|
|
|
expect(await evaluateModerator(plannerAbortedRules, ctx)).toBe(END);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("planner planned → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "planner", meta: { status: "planned", phases: [] } }]);
|
|
|
|
|
expect(await evaluateModerator(plannerAbortedRules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 5: reviewApproved / testsPassed (negative index) ────────
|
|
|
|
|
|
|
|
|
|
describe("Step 5: last-step conditions", () => {
|
|
|
|
|
const rules: ModeratorRule[] = [
|
|
|
|
|
{
|
|
|
|
|
from: "reviewer",
|
|
|
|
|
to: "tester",
|
|
|
|
|
when: "steps[-1].meta.status = 'approved'",
|
|
|
|
|
},
|
|
|
|
|
{ from: "reviewer", to: "coder", when: null },
|
|
|
|
|
{
|
|
|
|
|
from: "tester",
|
|
|
|
|
to: "committer",
|
|
|
|
|
when: "steps[-1].meta.status = 'passed'",
|
|
|
|
|
},
|
|
|
|
|
{ from: "tester", to: "coder", when: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test("reviewer approved → tester", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "reviewer", meta: { status: "approved" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("tester");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("reviewer changes-requested → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "reviewer", meta: { status: "changes-requested" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("tester passed → committer", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "tester", meta: { status: "passed" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("committer");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("tester failed → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "tester", meta: { status: "failed" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 6: allPhasesComplete condition ──────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("Step 6: allPhasesComplete", () => {
|
|
|
|
|
// The JSONata expression checks if all planned phase hashes appear in coder completedPhase values.
|
|
|
|
|
// Approximation using $count approach:
|
|
|
|
|
// Use JSONata 'in' operator inside $filter to check if each planned phase hash
|
|
|
|
|
// appears in the coder completedPhase values.
|
|
|
|
|
const allPhasesExpr =
|
|
|
|
|
"$count(steps[role='planner'].meta.phases) = 0 or $count(steps[role='planner'].meta.phases ~> $filter(function($p) { $p.hash in steps[role='coder'].meta.completedPhase })) >= $count(steps[role='planner'].meta.phases)";
|
|
|
|
|
|
|
|
|
|
const rules: ModeratorRule[] = [
|
|
|
|
|
{ from: "coder", to: "reviewer", when: allPhasesExpr },
|
|
|
|
|
{ from: "coder", to: "coder", when: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test("no phases (empty array) → all complete → reviewer", async () => {
|
|
|
|
|
const ctx = makeCtx([
|
|
|
|
|
{ role: "planner", meta: { status: "planned", phases: [] } },
|
|
|
|
|
{ role: "coder", meta: { completedPhase: "PHASE001" } },
|
|
|
|
|
]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("reviewer");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("all phases completed → reviewer", async () => {
|
|
|
|
|
const ctx = makeCtx([
|
|
|
|
|
{
|
|
|
|
|
role: "planner",
|
|
|
|
|
meta: {
|
|
|
|
|
status: "planned",
|
|
|
|
|
phases: [{ hash: "PHASE001" }, { hash: "PHASE002" }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ role: "coder", meta: { completedPhase: "PHASE001" } },
|
|
|
|
|
{ role: "coder", meta: { completedPhase: "PHASE002" } },
|
|
|
|
|
]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("reviewer");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("not all phases completed → coder (loop)", async () => {
|
|
|
|
|
const ctx = makeCtx([
|
|
|
|
|
{
|
|
|
|
|
role: "planner",
|
|
|
|
|
meta: {
|
|
|
|
|
status: "planned",
|
|
|
|
|
phases: [{ hash: "PHASE001" }, { hash: "PHASE002" }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ role: "coder", meta: { completedPhase: "PHASE001" } },
|
|
|
|
|
]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Step 7: Full develop workflow routing table ───────────────────
|
|
|
|
|
|
|
|
|
|
describe("Step 7: full develop workflow routing", () => {
|
|
|
|
|
const allPhasesExpr =
|
|
|
|
|
"$count(steps[role='planner'].meta.phases) = 0 or $count(steps[role='planner'].meta.phases ~> $filter(function($p) { $p.hash in steps[role='coder'].meta.completedPhase })) >= $count(steps[role='planner'].meta.phases)";
|
|
|
|
|
|
|
|
|
|
const rules: ModeratorRule[] = [
|
|
|
|
|
{ from: START, to: "planner", when: null },
|
|
|
|
|
{ from: "planner", to: END, when: "steps[role='planner'].meta.status = 'aborted'" },
|
|
|
|
|
{ from: "planner", to: "coder", when: null },
|
|
|
|
|
{ from: "coder", to: "reviewer", when: allPhasesExpr },
|
|
|
|
|
{ from: "coder", to: "coder", when: null },
|
|
|
|
|
{ from: "reviewer", to: "tester", when: "steps[-1].meta.status = 'approved'" },
|
|
|
|
|
{ from: "reviewer", to: "coder", when: null },
|
|
|
|
|
{ from: "tester", to: "committer", when: "steps[-1].meta.status = 'passed'" },
|
|
|
|
|
{ from: "tester", to: "coder", when: null },
|
|
|
|
|
{ from: "committer", to: END, when: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test("initial state → planner", async () => {
|
|
|
|
|
expect(await evaluateModerator(rules, makeCtx([]))).toBe("planner");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("planner planned, no phases → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "planner", meta: { status: "planned", phases: [] } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("planner aborted → __end__", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe(END);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("coder completed single phase → reviewer", async () => {
|
|
|
|
|
const ctx = makeCtx([
|
|
|
|
|
{ role: "planner", meta: { status: "planned", phases: [{ hash: "PH1" }] } },
|
|
|
|
|
{ role: "coder", meta: { completedPhase: "PH1" } },
|
|
|
|
|
]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("reviewer");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("coder incomplete phases → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([
|
|
|
|
|
{
|
|
|
|
|
role: "planner",
|
|
|
|
|
meta: { status: "planned", phases: [{ hash: "PH1" }, { hash: "PH2" }] },
|
|
|
|
|
},
|
|
|
|
|
{ role: "coder", meta: { completedPhase: "PH1" } },
|
|
|
|
|
]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("reviewer approved → tester", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "reviewer", meta: { status: "approved" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("tester");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("reviewer rejected → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "reviewer", meta: { status: "changes-requested" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("tester passed → committer", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "tester", meta: { status: "passed" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("committer");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("tester failed → coder", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "tester", meta: { status: "failed" } }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe("coder");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("committer done → __end__", async () => {
|
|
|
|
|
const ctx = makeCtx([{ role: "committer", meta: {} }]);
|
|
|
|
|
expect(await evaluateModerator(rules, ctx)).toBe(END);
|
|
|
|
|
});
|
|
|
|
|
});
|