555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { workflowId } from "@/lib/types/ids";
|
|
import type { WorkflowStep } from "@/lib/types/http";
|
|
import type {
|
|
OnTurnCompleteAction,
|
|
OnTurnStartAction,
|
|
TransitionConfig,
|
|
} from "@/lib/types/workflow-actions";
|
|
import {
|
|
analyzeIntroducedWorkflowReplayCycles,
|
|
analyzeWorkflowReplayCycles,
|
|
} from "./replay-cycle-analysis";
|
|
|
|
const NOW = "2026-07-15T00:00:00.000Z";
|
|
const SHORT_RETURN_STEP_ID = "short-return";
|
|
const WORK_STEP_ID = "work";
|
|
const EXISTING_STEP_ID = "a-existing";
|
|
const ALTERNATE_STEP_ID = "z-existing";
|
|
|
|
function step(
|
|
id: string,
|
|
position: number,
|
|
options: {
|
|
autoStart?: boolean;
|
|
name?: string;
|
|
prompt?: string;
|
|
onTurnStart?: OnTurnStartAction[];
|
|
onTurnComplete?: OnTurnCompleteAction[];
|
|
} = {},
|
|
): WorkflowStep {
|
|
return {
|
|
id,
|
|
workflow_id: workflowId("workflow-1"),
|
|
name: options.name ?? id,
|
|
position,
|
|
color: "bg-neutral-400",
|
|
prompt: options.prompt,
|
|
events: {
|
|
on_enter: options.autoStart ? [{ type: "auto_start_agent" }] : [],
|
|
on_turn_start: options.onTurnStart,
|
|
on_turn_complete: options.onTurnComplete,
|
|
},
|
|
created_at: NOW,
|
|
updated_at: NOW,
|
|
};
|
|
}
|
|
|
|
function moveToStep(target: string, config: TransitionConfig = {}): OnTurnCompleteAction {
|
|
return { type: "move_to_step", config: { ...config, step_id: target } };
|
|
}
|
|
|
|
function guardedMoveToStep(target: string): OnTurnCompleteAction {
|
|
return moveToStep(target, {
|
|
if: { wait_for_quorum: { role: "reviewer", threshold: "all_approve" } },
|
|
});
|
|
}
|
|
|
|
describe("replay cycle detection", () => {
|
|
it("reports a fully automatic next/previous replay cycle as blocking", () => {
|
|
const diagnostics = analyzeWorkflowReplayCycles([
|
|
step("build", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next" }],
|
|
}),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_previous" }],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostics).toHaveLength(2);
|
|
expect(diagnostics[0]).toEqual({
|
|
identity: expect.any(String),
|
|
severity: "blocking",
|
|
autoStartStepId: "build",
|
|
autoStartStepName: "build",
|
|
affectedStepIds: ["build", "review"],
|
|
trace: [
|
|
{
|
|
sourceStepId: "build",
|
|
sourceStepName: "build",
|
|
trigger: "on_turn_complete",
|
|
actionKind: "move_to_next",
|
|
destinationStepId: "review",
|
|
destinationStepName: "review",
|
|
requiresUserInvolvement: false,
|
|
},
|
|
{
|
|
sourceStepId: "review",
|
|
sourceStepName: "review",
|
|
trigger: "on_turn_complete",
|
|
actionKind: "move_to_previous",
|
|
destinationStepId: "build",
|
|
destinationStepName: "build",
|
|
requiresUserInvolvement: false,
|
|
},
|
|
],
|
|
promptSource: "task_description",
|
|
});
|
|
});
|
|
|
|
it("treats on_turn_start from an auto-start step as automatic", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnStart: [{ type: "move_to_next" }],
|
|
}),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_previous" }],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("blocking");
|
|
expect(diagnostic.trace.map((hop) => hop.requiresUserInvolvement)).toEqual([false, false]);
|
|
});
|
|
|
|
it("marks on_turn_start from a non-auto-start step as user-mediated", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next" }],
|
|
}),
|
|
step("review", 1, { onTurnStart: [{ type: "move_to_next" }] }),
|
|
step("finish", 2, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("warning");
|
|
expect(diagnostic.trace.map((hop) => hop.requiresUserInvolvement)).toEqual([
|
|
false,
|
|
true,
|
|
false,
|
|
]);
|
|
});
|
|
|
|
it("does not report the built-in Kanban return because on_turn_start skips on_enter", () => {
|
|
const diagnostics = analyzeWorkflowReplayCycles([
|
|
step("backlog", 0, {
|
|
onTurnStart: [{ type: "move_to_next" }],
|
|
onTurnComplete: [moveToStep("review")],
|
|
}),
|
|
step("in-progress", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("review")],
|
|
}),
|
|
step("review", 2, { onTurnStart: [{ type: "move_to_previous" }] }),
|
|
step("done", 3, {
|
|
onTurnStart: [{ type: "move_to_step", config: { step_id: "in-progress" } }],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostics).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("replay cycle severity", () => {
|
|
it("marks an on_turn_complete hop from a non-auto-start source as user-mediated", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next" }],
|
|
}),
|
|
step("review", 1, { onTurnComplete: [{ type: "move_to_previous" }] }),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("warning");
|
|
expect(diagnostic.trace.map((hop) => hop.requiresUserInvolvement)).toEqual([false, true]);
|
|
});
|
|
|
|
it("marks an approval-gated hop as user-mediated", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next", config: { requires_approval: true } }],
|
|
}),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_previous" }],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("warning");
|
|
expect(diagnostic.trace.map((hop) => hop.requiresUserInvolvement)).toEqual([true, false]);
|
|
});
|
|
|
|
it("treats guarded transitions as possible automatic paths", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [
|
|
{
|
|
type: "move_to_next",
|
|
config: { if: { wait_for_quorum: { role: "reviewer", threshold: "all_approve" } } },
|
|
},
|
|
],
|
|
}),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_previous" }],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("blocking");
|
|
});
|
|
});
|
|
|
|
describe("replay transition resolution", () => {
|
|
it("ignores lower-priority moves after an unconditional transition", () => {
|
|
const diagnostics = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next" }, moveToStep("work")],
|
|
}),
|
|
step("done", 1),
|
|
]);
|
|
|
|
expect(diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("keeps fallback moves after a guarded transition", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [
|
|
{
|
|
type: "move_to_next",
|
|
config: { if: { wait_for_quorum: { role: "reviewer", threshold: "all_approve" } } },
|
|
},
|
|
moveToStep("work"),
|
|
],
|
|
}),
|
|
step("done", 1),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("blocking");
|
|
expect(diagnostic.trace).toHaveLength(1);
|
|
expect(diagnostic.trace[0].actionKind).toBe("move_to_step");
|
|
});
|
|
|
|
it("resolves explicit targets and ignores dangling targets", () => {
|
|
const valid = analyzeWorkflowReplayCycles([
|
|
step("work", 0, { autoStart: true, onTurnComplete: [moveToStep("review")] }),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
const dangling = analyzeWorkflowReplayCycles([
|
|
step("work", 0, { autoStart: true, onTurnComplete: [moveToStep("missing")] }),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
|
|
expect(valid).toHaveLength(2);
|
|
expect(dangling).toEqual([]);
|
|
});
|
|
|
|
it("ignores malformed explicit targets without throwing", () => {
|
|
const malformed = { type: "move_to_step" } as OnTurnCompleteAction;
|
|
|
|
expect(
|
|
analyzeWorkflowReplayCycles([
|
|
step("work", 0, { autoStart: true, onTurnComplete: [malformed] }),
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("uses position order for relative transitions after a reorder", () => {
|
|
const before = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next" }],
|
|
}),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_previous" }],
|
|
}),
|
|
step("done", 2),
|
|
]);
|
|
const after = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_next" }],
|
|
}),
|
|
step("review", 2, {
|
|
autoStart: true,
|
|
onTurnComplete: [{ type: "move_to_previous" }],
|
|
}),
|
|
step("done", 1),
|
|
]);
|
|
|
|
expect(before.map((diagnostic) => diagnostic.autoStartStepId)).toEqual(["work", "review"]);
|
|
expect(after).toEqual([]);
|
|
});
|
|
|
|
it("does not report cycles that never re-enter an auto-start step", () => {
|
|
const diagnostics = analyzeWorkflowReplayCycles([
|
|
step("backlog", 0, { onTurnComplete: [{ type: "move_to_next" }] }),
|
|
step("review", 1, { onTurnComplete: [{ type: "move_to_previous" }] }),
|
|
]);
|
|
|
|
expect(diagnostics).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("replay cycle search bounds", () => {
|
|
it("selects deterministically without enumerating every path in a dense branching graph", () => {
|
|
const layerCount = 16;
|
|
const branchCount = 3;
|
|
const layers = Array.from({ length: layerCount }, (_, layerIndex) =>
|
|
Array.from(
|
|
{ length: branchCount },
|
|
(_, branchIndex) => `layer-${String(layerIndex).padStart(2, "0")}-${branchIndex}`,
|
|
),
|
|
);
|
|
const denseSteps = [
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [...layers[0]].reverse().map(guardedMoveToStep),
|
|
}),
|
|
...layers.flatMap((layer, layerIndex) =>
|
|
layer.map((id, branchIndex) =>
|
|
step(id, layerIndex * branchCount + branchIndex + 1, {
|
|
onTurnComplete:
|
|
layerIndex === layerCount - 1
|
|
? [moveToStep("work")]
|
|
: [...layers[layerIndex + 1]].reverse().map(guardedMoveToStep),
|
|
}),
|
|
),
|
|
),
|
|
];
|
|
|
|
const [diagnostic] = analyzeWorkflowReplayCycles(denseSteps);
|
|
|
|
expect(diagnostic.trace.map((hop) => hop.destinationStepId)).toEqual([
|
|
...layers.map(([lexicallyFirst]) => lexicallyFirst),
|
|
"work",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("replay diagnostic selection", () => {
|
|
it("selects a blocking trace before a warning trace", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnStart: [{ type: "move_to_step", config: { step_id: "manual-return" } }],
|
|
onTurnComplete: [moveToStep(SHORT_RETURN_STEP_ID)],
|
|
}),
|
|
step("manual-return", 1, { onTurnComplete: [moveToStep("work")] }),
|
|
step(SHORT_RETURN_STEP_ID, 2, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("blocking");
|
|
expect(diagnostic.trace.map((hop) => hop.destinationStepId)).toEqual([
|
|
SHORT_RETURN_STEP_ID,
|
|
"work",
|
|
]);
|
|
});
|
|
|
|
it("selects the shortest trace when candidates have equal severity", () => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
onTurnStart: [{ type: "move_to_step", config: { step_id: SHORT_RETURN_STEP_ID } }],
|
|
onTurnComplete: [moveToStep("long-a")],
|
|
}),
|
|
step(SHORT_RETURN_STEP_ID, 1, { onTurnComplete: [moveToStep("work")] }),
|
|
step("long-a", 2, { onTurnComplete: [moveToStep("long-b")] }),
|
|
step("long-b", 3, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.severity).toBe("warning");
|
|
expect(diagnostic.trace.map((hop) => hop.destinationStepId)).toEqual([
|
|
SHORT_RETURN_STEP_ID,
|
|
"work",
|
|
]);
|
|
});
|
|
|
|
it("keeps identity stable across names and prompt changes", () => {
|
|
const original = analyzeWorkflowReplayCycles([
|
|
step("work", 0, { autoStart: true, onTurnComplete: [moveToStep("review")] }),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
const renamed = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
name: "Implementation",
|
|
prompt: "Use {{task_prompt}} carefully",
|
|
onTurnComplete: [moveToStep("review")],
|
|
}),
|
|
step("review", 1, {
|
|
autoStart: true,
|
|
name: "Code review",
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
|
|
expect(renamed.map((diagnostic) => diagnostic.identity)).toEqual(
|
|
original.map((diagnostic) => diagnostic.identity),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[undefined, "task_description"],
|
|
["", "task_description"],
|
|
["Before\n{{task_prompt}}\nAfter", "step_prompt_with_task_description"],
|
|
["Review the implementation", "step_prompt"],
|
|
] as const)("classifies prompt %j as %s", (prompt, expected) => {
|
|
const [diagnostic] = analyzeWorkflowReplayCycles([
|
|
step("work", 0, {
|
|
autoStart: true,
|
|
prompt,
|
|
onTurnComplete: [moveToStep("work")],
|
|
}),
|
|
]);
|
|
|
|
expect(diagnostic.promptSource).toBe(expected);
|
|
});
|
|
|
|
it("uses input order to break equal-position ties and returns diagnostics in that order", () => {
|
|
const steps = [
|
|
step("second", 0, { autoStart: true, onTurnComplete: [moveToStep("second")] }),
|
|
step("first", 0, { autoStart: true, onTurnComplete: [moveToStep("first")] }),
|
|
];
|
|
|
|
expect(
|
|
analyzeWorkflowReplayCycles(steps).map((diagnostic) => diagnostic.autoStartStepId),
|
|
).toEqual(["second", "first"]);
|
|
});
|
|
});
|
|
|
|
describe("introduced replay cycle inventory", () => {
|
|
it("detects a new alternate loop when the preferred trace is unchanged", () => {
|
|
const baseline = [
|
|
step(WORK_STEP_ID, 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [guardedMoveToStep(EXISTING_STEP_ID)],
|
|
}),
|
|
step(EXISTING_STEP_ID, 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep(WORK_STEP_ID)],
|
|
}),
|
|
step("z-new", 2, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep(WORK_STEP_ID)],
|
|
}),
|
|
];
|
|
const proposed = [
|
|
{
|
|
...baseline[0],
|
|
events: {
|
|
...baseline[0].events,
|
|
on_turn_complete: [guardedMoveToStep(EXISTING_STEP_ID), guardedMoveToStep("z-new")],
|
|
},
|
|
},
|
|
...baseline.slice(1),
|
|
];
|
|
|
|
expect(analyzeWorkflowReplayCycles(proposed)[0].trace[0].destinationStepId).toBe(
|
|
EXISTING_STEP_ID,
|
|
);
|
|
expect(
|
|
analyzeIntroducedWorkflowReplayCycles(baseline, proposed)[0].trace.map(
|
|
(hop) => hop.destinationStepId,
|
|
),
|
|
).toEqual(["z-new", WORK_STEP_ID]);
|
|
});
|
|
|
|
it("does not reclassify an existing alternate loop when the preferred trace is removed", () => {
|
|
const baseline = [
|
|
step(WORK_STEP_ID, 0, {
|
|
autoStart: true,
|
|
onTurnComplete: [guardedMoveToStep("a-preferred"), guardedMoveToStep(ALTERNATE_STEP_ID)],
|
|
}),
|
|
step("a-preferred", 1, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep(WORK_STEP_ID)],
|
|
}),
|
|
step(ALTERNATE_STEP_ID, 2, {
|
|
autoStart: true,
|
|
onTurnComplete: [moveToStep(WORK_STEP_ID)],
|
|
}),
|
|
];
|
|
const proposed = [
|
|
{
|
|
...baseline[0],
|
|
events: {
|
|
...baseline[0].events,
|
|
on_turn_complete: [guardedMoveToStep(ALTERNATE_STEP_ID)],
|
|
},
|
|
},
|
|
...baseline.slice(1),
|
|
];
|
|
|
|
expect(analyzeWorkflowReplayCycles(proposed)[0].trace[0].destinationStepId).toBe(
|
|
ALTERNATE_STEP_ID,
|
|
);
|
|
expect(analyzeIntroducedWorkflowReplayCycles(baseline, proposed)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("bounded replay cycle inventory", () => {
|
|
it("fails conservatively when alternate cycle inventory is truncated", () => {
|
|
const existingStepIds = Array.from(
|
|
{ length: 257 },
|
|
(_, index) => `existing-${index.toString().padStart(3, "0")}`,
|
|
);
|
|
const baseline = [
|
|
step(WORK_STEP_ID, 0, {
|
|
autoStart: true,
|
|
onTurnComplete: existingStepIds.map(guardedMoveToStep),
|
|
}),
|
|
...existingStepIds.map((stepId, index) =>
|
|
step(stepId, index + 1, {
|
|
onTurnComplete: [moveToStep(WORK_STEP_ID)],
|
|
}),
|
|
),
|
|
];
|
|
const newStepId = "new-beyond-inventory-cap";
|
|
const proposed = [
|
|
{
|
|
...baseline[0],
|
|
events: {
|
|
...baseline[0].events,
|
|
on_turn_complete: [
|
|
...(baseline[0].events?.on_turn_complete ?? []),
|
|
guardedMoveToStep(newStepId),
|
|
],
|
|
},
|
|
},
|
|
...baseline.slice(1),
|
|
step(newStepId, baseline.length, {
|
|
onTurnComplete: [moveToStep(WORK_STEP_ID)],
|
|
}),
|
|
];
|
|
|
|
expect(analyzeIntroducedWorkflowReplayCycles(baseline, proposed)).toHaveLength(1);
|
|
});
|
|
});
|