feat: JSONata moderator engine (Phase 2 of #294) #296

Merged
xiaoju merged 1 commits from feat/294-jsonata-moderator into main 2026-05-18 02:13:56 +00:00
4 changed files with 358 additions and 0 deletions
@@ -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);
});
});
+3
View File
@@ -28,5 +28,8 @@
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"jsonata": "^2.2.0"
}
}
+5
View File
@@ -54,3 +54,8 @@ export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
// ── JSONata moderator ──────────────────────────────────────────────
export type { ModeratorRule } from "./jsonata-moderator.js";
export { evaluateModerator } from "./jsonata-moderator.js";
@@ -0,0 +1,66 @@
import jsonata from "jsonata";
import type { ModeratorContext, RoleMeta, StartStep } from "./types.js";
import { END, START } from "./types.js";
// ── Types ───────────────────────────────────────────────────────────
export type ModeratorRule = {
from: string;
to: string;
when: string | null;
};
type JsonataContext = {
threadId: string;
depth: number;
start: StartStep;
steps: ReadonlyArray<{ role: string; meta: Record<string, unknown> }>;
};
// ── Evaluator ───────────────────────────────────────────────────────
/**
* Evaluate a JSONata-based moderator rule set against the given thread context.
* Returns the next role name or '__end__'.
*/
export async function evaluateModerator(
rules: ReadonlyArray<ModeratorRule>,
context: ModeratorContext,
): Promise<string> {
const lastStep = context.steps.length > 0 ? context.steps[context.steps.length - 1] : null;
const currentState: string = lastStep ? lastStep.role : START;
const matching = rules.filter((r) => r.from === currentState);
const jsonataCtx: JsonataContext = {
threadId: context.threadId,
depth: context.depth,
start: context.start,
steps: context.steps as ReadonlyArray<{ role: string; meta: Record<string, unknown> }>,
};
for (const rule of matching) {
if (rule.when === null) {
return rule.to;
}
const expr = jsonata(rule.when);
const result = await expr.evaluate(jsonataCtx);
if (result) {
return rule.to;
}
}
return END;
}
// ── Context helper ──────────────────────────────────────────────────
/** Build a ModeratorContext from its constituent parts (convenience for tests / callers). */
export function makeModeratorContext<M extends RoleMeta>(
ctx: ModeratorContext<M>,
): ModeratorContext<M> {
return ctx;
}