Files
fusion/packages/engine/src/__tests__/workflow-graph-loop.test.ts
2026-06-07 23:56:25 -07:00

151 lines
5.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
const task = { id: "FN-LOOP" } as TaskDetail;
function loopIr(config: Record<string, unknown>, extraEdges: WorkflowIr["edges"] = []): WorkflowIr {
return {
version: "v2",
name: "loop-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "loop",
kind: "loop",
config: {
template: {
nodes: [
{ id: "ask", kind: "prompt", config: { prompt: "try" } },
{ id: "check", kind: "gate", config: { prompt: "done?" } },
],
edges: [{ from: "ask", to: "check" }],
},
...config,
},
},
{ id: "exhausted", kind: "hold", config: { release: "manual" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "loop" },
{ from: "loop", to: "end", condition: "success" },
...extraEdges,
],
};
}
describe("WorkflowGraphExecutor loop", () => {
it("exits successfully when the template output matches immediately", async () => {
const calls: string[] = [];
const prompt: WorkflowNodeHandler = async (node) => {
calls.push(node.id);
return { outcome: "success", value: node.id === "check" ? "DONE" : "working" };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt, gate: prompt } });
const result = await executor.run(
task,
settingsOn(),
loopIr({ maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } }),
);
expect(result.outcome).toBe("success");
expect(calls).toEqual(["ask", "check"]);
expect(result.visitedNodeIds).toEqual(expect.arrayContaining(["loop", "loop#1:ask", "loop#1:check"]));
expect(result.context["node:loop:loop"]).toMatchObject({ iterations: 1, exitReason: "matched" });
expect(result.context["loop:active"]).toBeUndefined();
});
it("keeps iterating until the configured output string appears", async () => {
let checks = 0;
const handler: WorkflowNodeHandler = async (node) => {
if (node.id !== "check") return { outcome: "success", value: "working" };
checks += 1;
return { outcome: "success", value: checks === 3 ? "DONE" : "KEEP_GOING" };
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler, gate: handler, hold: async () => ({ outcome: "success" }) },
});
const result = await executor.run(
task,
settingsOn(),
loopIr({ maxIterations: 4, exitWhen: { type: "output-contains", value: "DONE" } }),
);
expect(result.outcome).toBe("success");
expect(checks).toBe(3);
expect(result.context["node:loop:loop"]).toMatchObject({ iterations: 3, exitReason: "matched" });
expect(result.context["node:check:value"]).toBe("DONE");
});
it("routes iteration exhaustion as a failure outcome value", async () => {
const handler = vi.fn(async () => ({ outcome: "success" as const, value: "not yet" }));
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler, gate: handler, hold: async () => ({ outcome: "success" }) },
});
const result = await executor.run(
task,
settingsOn(),
loopIr(
{ maxIterations: 2, exitWhen: { type: "output-contains", value: "DONE" } },
[{ from: "loop", to: "exhausted", condition: "outcome:loop-iteration-exhausted" }],
),
);
expect(result.outcome).toBe("success");
expect(handler).toHaveBeenCalledTimes(4);
expect(result.context["node:loop:outcome"]).toBe("failure");
expect(result.context["node:loop:value"]).toBe("loop-iteration-exhausted");
});
it("routes timeout as a failure outcome value", async () => {
let now = 0;
const handler: WorkflowNodeHandler = async () => {
now += 10;
return { outcome: "success", value: "not yet" };
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler, gate: handler, hold: async () => ({ outcome: "success" }) },
runLoopNowForTests: () => now,
});
const result = await executor.run(
task,
settingsOn(),
loopIr(
{ maxIterations: 10, timeoutMs: 15, exitWhen: { type: "output-contains", value: "DONE" } },
[{ from: "loop", to: "exhausted", condition: "outcome:loop-timeout" }],
),
);
expect(result.outcome).toBe("success");
expect(result.context["node:loop:value"]).toBe("loop-timeout");
});
it("can match a regex against a selected template node value", async () => {
const handler: WorkflowNodeHandler = async (node: WorkflowIrNode) => ({
outcome: "success",
value: node.id === "ask" ? "ticket READY-42" : "ignored",
});
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler, gate: handler } });
const result = await executor.run(
task,
settingsOn(),
loopIr({
maxIterations: 2,
exitWhen: { type: "output-matches", nodeId: "ask", pattern: "READY-\\d+" },
}),
);
expect(result.outcome).toBe("success");
expect(result.context["node:loop:loop"]).toMatchObject({ exitReason: "matched" });
});
});