680 lines
27 KiB
TypeScript
680 lines
27 KiB
TypeScript
// @ts-nocheck
|
|
// FN-6226 surface enumeration: engine-only behavior, so desktop/mobile
|
|
// breakpoints are N/A. These tests cover legacy seams, graph runtime
|
|
// primitives, custom graph prompt/script/gate nodes under a custom workflow
|
|
// selection, builtin/default selection behavior via the legacy seam, fast /
|
|
// standard / undefined executionMode data states, and the executor tool
|
|
// injection surface for fn_review_step vs mandatory fn_task_done.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import "./executor-test-helpers.js";
|
|
import { getBuiltinWorkflow } from "@fusion/core";
|
|
import { TaskExecutor } from "../executor.js";
|
|
import { WorkflowGraphTaskRunner } from "../workflow-graph-task-runner.js";
|
|
import {
|
|
createMockStore,
|
|
mockedCreateFnAgent,
|
|
mockedExistsSync,
|
|
resetExecutorMocks,
|
|
} from "./executor-test-helpers.js";
|
|
|
|
const now = "2026-06-10T00:00:00.000Z";
|
|
|
|
function task(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "FN-6226",
|
|
title: "Fast mode workflow task",
|
|
description: "exercise fast mode",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# Task\n## Steps\n### Step 1\n- [ ] do it",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeExecutorForTask(liveTask = task()) {
|
|
const store = createMockStore();
|
|
store.getTask.mockImplementation(async (id: string) => ({ ...liveTask, id }));
|
|
store.getSettings.mockResolvedValue({
|
|
autoMerge: false,
|
|
experimentalFeatures: { workflowGraphExecutor: true },
|
|
});
|
|
return { store, executor: new TaskExecutor(store, "/tmp/test") };
|
|
}
|
|
|
|
function workflowResult() {
|
|
return { allPassed: true, results: [] };
|
|
}
|
|
|
|
describe("fast mode workflow/runtime invariants", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("graph executor with a custom workflow skips custom pre-merge prompt/gate nodes in fast mode", async () => {
|
|
const { store, executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
|
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
|
|
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
|
|
|
|
const definition = {
|
|
id: "WF-fast-custom",
|
|
name: "Fast custom",
|
|
description: "custom workflow",
|
|
kind: "workflow",
|
|
layout: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
ir: {
|
|
version: "v1",
|
|
name: "Fast custom",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "custom-review", kind: "prompt", config: { prompt: "Review this" } },
|
|
{ id: "custom-gate", kind: "gate", config: { prompt: "Gate this", gateMode: "gate" } },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "custom-review" },
|
|
{ from: "custom-review", to: "custom-gate" },
|
|
{ from: "custom-gate", to: "end" },
|
|
],
|
|
},
|
|
};
|
|
|
|
const runner = new WorkflowGraphTaskRunner({
|
|
store: {
|
|
getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-custom", stepIds: [] }),
|
|
getWorkflowDefinition: vi.fn(async () => definition),
|
|
},
|
|
seams: (executor as any).createAuthoritativeWorkflowSeams({}),
|
|
primitives: (executor as any).createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } }),
|
|
runCustomNode: (node, nodeTask, context) => (executor as any).runGraphCustomNode(node, nodeTask, {}, undefined),
|
|
});
|
|
|
|
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
|
expect(result.disposition).toBe("completed");
|
|
expect(result.visitedNodeIds).toEqual(["start", "custom-review", "custom-gate"]);
|
|
expect(executeStep).not.toHaveBeenCalled();
|
|
expect(executeScript).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-6226",
|
|
"Fast mode — custom graph node 'custom-review' skipped",
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("falls back to the runner task when prepareWorktree cannot trust the live row", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({ ...task({ id: "FN-OTHER", worktree: "/tmp/wrong" }) });
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const result = await (executor as any)
|
|
.createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } })
|
|
.prepareWorktree(
|
|
{ run: { taskId: "FN-6226" }, node: { node: { id: "execute" }, context: {} } },
|
|
task({ id: "FN-6226", worktree: "/tmp/right", branch: "fusion/fn-6226" }),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
outcome: "success",
|
|
data: {
|
|
worktreePath: "/tmp/right",
|
|
branchName: "fusion/fn-6226",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("fast builtin:coding still parses and executes steps while disabled optional groups stay inert", async () => {
|
|
const calls: string[] = [];
|
|
const prompt = "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files";
|
|
const taskSteps = [{ name: "Do the work", status: "pending" }];
|
|
const seams = {
|
|
planning: vi.fn(async () => {
|
|
calls.push("plan");
|
|
return { outcome: "success", value: "planned" };
|
|
}),
|
|
execute: vi.fn(async () => {
|
|
calls.push("legacy-execute");
|
|
return { outcome: "success", value: "implemented" };
|
|
}),
|
|
review: vi.fn(async () => {
|
|
calls.push("review");
|
|
return { outcome: "success", value: "approved" };
|
|
}),
|
|
merge: vi.fn(async () => {
|
|
calls.push("merge");
|
|
return { outcome: "success", value: "merged" };
|
|
}),
|
|
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
|
|
stepExecute: vi.fn(async (_task, context) => {
|
|
calls.push(`step-execute:${context["foreach:active"]?.stepIndex}`);
|
|
return { outcome: "success", value: "step-done" };
|
|
}),
|
|
};
|
|
const runner = new WorkflowGraphTaskRunner({
|
|
store: {
|
|
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
|
|
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
|
|
},
|
|
seams,
|
|
parseStepsDeps: {
|
|
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
|
|
writeSteps: async (target) => {
|
|
calls.push("parse");
|
|
target.steps = taskSteps;
|
|
},
|
|
},
|
|
runCustomNode: vi.fn(async (node) => {
|
|
calls.push(`custom:${node.id}`);
|
|
return { outcome: "success", value: "custom-ok" };
|
|
}),
|
|
});
|
|
|
|
const result = await runner.run(task({
|
|
id: "FN-6226",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: [],
|
|
prompt,
|
|
}), { experimentalFeatures: { workflowGraphExecutor: true } });
|
|
|
|
expect(result.disposition).toBe("completed");
|
|
expect(result.visitedNodeIds).toContain("parse");
|
|
expect(result.visitedNodeIds).toContain("steps#0:step-execute");
|
|
expect(result.visitedNodeIds).toContain("browser-verification");
|
|
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
|
expect(result.visitedNodeIds).toContain("code-review");
|
|
expect(result.visitedNodeIds).not.toContain("code-review::code-review-step");
|
|
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
|
expect(calls).toContain("parse");
|
|
expect(calls).toContain("step-execute:0");
|
|
expect(calls).not.toContain("legacy-execute");
|
|
/*
|
|
FNXC:WorkflowFastMode 2026-07-01-00:00:
|
|
The default built-in now resolves to the stepwise final-review workflow. In raw fast-mode compatibility runs, default-on review groups are skipped as custom nodes and the legacy review seam is not invoked; the merge seam remains the lifecycle suffix assertion.
|
|
*/
|
|
expect(seams.review).not.toHaveBeenCalled();
|
|
expect(seams.merge).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("raw fast mode skips skill executor nodes when primitives are unavailable", async () => {
|
|
const runCustomNode = vi.fn(async () => ({ outcome: "success", value: "ran-skill" }));
|
|
const runner = new WorkflowGraphTaskRunner({
|
|
store: {
|
|
getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-skill", stepIds: [] }),
|
|
getWorkflowDefinition: vi.fn(async () => ({
|
|
id: "WF-fast-skill",
|
|
name: "Fast skill",
|
|
description: "custom skill workflow",
|
|
kind: "workflow",
|
|
layout: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
ir: {
|
|
version: "v1",
|
|
name: "Fast skill",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "skill-review", kind: "prompt", config: { executor: "skill", skillName: "compound-engineering:ce-code-review" } },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "skill-review" },
|
|
{ from: "skill-review", to: "end", condition: "success" },
|
|
],
|
|
},
|
|
})),
|
|
},
|
|
seams: {
|
|
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
|
|
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
|
|
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
|
|
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
|
|
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
|
|
},
|
|
runCustomNode,
|
|
});
|
|
|
|
const result = await runner.run(task({ executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
|
|
|
expect(result.disposition).toBe("completed");
|
|
expect(result.visitedNodeIds).toEqual(["start", "skill-review"]);
|
|
expect(runCustomNode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("raw fast mode still invokes non-executable review seam nodes", async () => {
|
|
const review = vi.fn(async () => ({ outcome: "success" as const, value: "approved" }));
|
|
const runCustomNode = vi.fn(async () => ({ outcome: "failure" as const, value: "unexpected-custom-node" }));
|
|
const runner = new WorkflowGraphTaskRunner({
|
|
store: {
|
|
getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-review-seam", stepIds: [] }),
|
|
getWorkflowDefinition: vi.fn(async () => ({
|
|
id: "WF-fast-review-seam",
|
|
name: "Fast review seam",
|
|
description: "custom review seam workflow",
|
|
kind: "workflow",
|
|
layout: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
ir: {
|
|
version: "v1",
|
|
name: "Fast review seam",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "review", kind: "prompt", config: { seam: "review" } },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "review" },
|
|
{ from: "review", to: "end", condition: "success" },
|
|
],
|
|
},
|
|
})),
|
|
},
|
|
seams: {
|
|
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
|
|
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
|
|
review,
|
|
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
|
|
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
|
|
},
|
|
runCustomNode,
|
|
});
|
|
|
|
const result = await runner.run(task({ executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
|
|
|
expect(result.disposition).toBe("completed");
|
|
expect(result.visitedNodeIds).toEqual(["start", "review"]);
|
|
expect(review).toHaveBeenCalledTimes(1);
|
|
expect(runCustomNode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fast builtin:coding executes explicitly selected optional-group template nodes", async () => {
|
|
const calls: string[] = [];
|
|
const prompt = "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files";
|
|
const taskSteps = [{ name: "Do the work", status: "pending" }];
|
|
const seams = {
|
|
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
|
|
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
|
|
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
|
|
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
|
|
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
|
|
stepExecute: vi.fn(async () => ({ outcome: "success", value: "step-done" })),
|
|
};
|
|
const runner = new WorkflowGraphTaskRunner({
|
|
store: {
|
|
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
|
|
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
|
|
},
|
|
seams,
|
|
parseStepsDeps: {
|
|
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
|
|
writeSteps: async (target) => {
|
|
target.steps = taskSteps;
|
|
},
|
|
},
|
|
runCustomNode: vi.fn(async (node) => {
|
|
calls.push(`custom:${node.id}`);
|
|
return { outcome: "success", value: "APPROVE" };
|
|
}),
|
|
});
|
|
|
|
const result = await runner.run(task({
|
|
id: "FN-7283",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: ["browser-verification"],
|
|
prompt,
|
|
}), { experimentalFeatures: { workflowGraphExecutor: true } });
|
|
|
|
expect(result.disposition).toBe("completed");
|
|
expect(result.visitedNodeIds).toContain("browser-verification::browser-verification-step");
|
|
expect(calls).toContain("custom:browser-verification-step");
|
|
expect(result.visitedNodeIds).toContain("code-review");
|
|
expect(result.visitedNodeIds).not.toContain("code-review::code-review-step");
|
|
});
|
|
|
|
it("blocks fast builtin:coding merge when parsed implementation proof is missing", async () => {
|
|
const liveTask = task({
|
|
id: "FN-7271",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: [],
|
|
column: "in-progress",
|
|
steps: [],
|
|
prompt: "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files",
|
|
});
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue(liveTask);
|
|
store.getTaskWorkflowSelection = vi.fn(() => ({ workflowId: "builtin:coding", stepIds: [] }));
|
|
store.getWorkflowDefinition = vi.fn(async (id: string) => getBuiltinWorkflow(id));
|
|
store.moveTask.mockResolvedValue({ ...liveTask, column: "in-review" });
|
|
const executor = new TaskExecutor(store, "/tmp/test") as any;
|
|
const mergeRequester = vi.fn(async () => ({ merged: true }));
|
|
executor.setMergeRequester(mergeRequester);
|
|
|
|
const result = await executor.createAuthoritativeWorkflowPrimitives({ autoMerge: true }).requestMerge(
|
|
{
|
|
run: { runId: "FN-7271:builtin:coding", taskId: "FN-7271", workflowId: "builtin-stepwise-final-review-coding" },
|
|
node: { node: { id: "merge" } },
|
|
},
|
|
liveTask,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
outcome: "failure",
|
|
value: "implementation-incomplete",
|
|
data: { reason: "implementation-incomplete" },
|
|
});
|
|
expect(mergeRequester).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-7271",
|
|
expect.stringContaining("Workflow merge blocked before requester: implementation did not run"),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("fast builtin:coding executes plain Steps-section headings from fast triage specs", async () => {
|
|
const calls: string[] = [];
|
|
const prompt = `# Task
|
|
|
|
## Steps
|
|
|
|
### Preflight
|
|
- [ ] inspect
|
|
|
|
### Implementation
|
|
- [ ] edit
|
|
|
|
### Testing & Verification
|
|
- [ ] test
|
|
`;
|
|
const seams = {
|
|
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
|
|
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
|
|
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
|
|
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
|
|
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
|
|
stepExecute: vi.fn(async (_task, context) => {
|
|
calls.push(`step-execute:${context["foreach:active"]?.stepIndex}`);
|
|
return { outcome: "success", value: "step-done" };
|
|
}),
|
|
};
|
|
const runner = new WorkflowGraphTaskRunner({
|
|
store: {
|
|
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
|
|
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
|
|
},
|
|
seams,
|
|
parseStepsDeps: {
|
|
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
|
|
writeSteps: async (target, steps) => {
|
|
target.steps = steps;
|
|
},
|
|
},
|
|
runCustomNode: vi.fn(async () => ({ outcome: "success" })),
|
|
});
|
|
|
|
const result = await runner.run(task({
|
|
id: "FN-7260",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: [],
|
|
prompt,
|
|
}), { experimentalFeatures: { workflowGraphExecutor: true } });
|
|
|
|
expect(result.disposition).toBe("completed");
|
|
expect(result.visitedNodeIds).toContain("steps#0:step-execute");
|
|
expect(result.visitedNodeIds).toContain("steps#1:step-execute");
|
|
expect(result.visitedNodeIds).toContain("steps#2:step-execute");
|
|
expect(calls).toEqual(["step-execute:0", "step-execute:1", "step-execute:2"]);
|
|
expect(seams.merge).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
["standard", "standard"],
|
|
["undefined", undefined],
|
|
["null", null],
|
|
])("runs custom pre-merge prompt nodes in %s execution mode", async (_label, executionMode) => {
|
|
const { executor } = makeExecutorForTask(task({ executionMode, worktree: "/tmp/wt" }));
|
|
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
|
|
|
|
const result = await (executor as any).runGraphCustomNode(
|
|
{ id: "custom-review", kind: "prompt", config: { prompt: "Review this" } },
|
|
task({ executionMode }),
|
|
{},
|
|
undefined,
|
|
);
|
|
|
|
expect(result.outcome).toBe("success");
|
|
expect(result.value).toBe("passed");
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["prompt", "script", "gate"])("skips custom %s nodes in fast mode before workflow-step execution", async (kind) => {
|
|
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
|
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
|
|
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
|
|
const config = kind === "script" ? { scriptName: "lint" } : { prompt: "check" };
|
|
|
|
const result = await (executor as any).runGraphCustomNode(
|
|
{ id: `custom-${kind}`, kind, config },
|
|
task({ executionMode: "fast" }),
|
|
{},
|
|
undefined,
|
|
);
|
|
|
|
expect(result).toMatchObject({ outcome: "success", value: "workflow-step-skipped" });
|
|
expect(executeStep).not.toHaveBeenCalled();
|
|
expect(executeScript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["completion-summary id", { id: "completion-summary", kind: "prompt", config: { prompt: "summarize" } }],
|
|
["summaryTarget task", { id: "custom-summary", kind: "prompt", config: { prompt: "summarize", summaryTarget: "task" } }],
|
|
])("does not skip completion summary nodes in fast mode by %s", async (_label, node) => {
|
|
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
|
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "Done." });
|
|
|
|
const result = await (executor as any).runGraphCustomNode(
|
|
node,
|
|
task({ executionMode: "fast" }),
|
|
{},
|
|
undefined,
|
|
);
|
|
|
|
expect(result).toMatchObject({ outcome: "success", value: "passed" });
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["prompt", "script", "gate"])("executes optional-group template %s nodes in fast mode", async (kind) => {
|
|
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
|
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
|
|
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
|
|
const config = kind === "script" ? { scriptName: "lint" } : { prompt: "check" };
|
|
|
|
const result = await (executor as any).runGraphCustomNode(
|
|
{ id: `custom-${kind}`, kind, config },
|
|
task({ executionMode: "fast" }),
|
|
{},
|
|
undefined,
|
|
{ "workflow:optionalGroupActive": "browser-verification" },
|
|
);
|
|
|
|
expect(result).toMatchObject({ outcome: "success" });
|
|
if (kind === "script") {
|
|
expect(executeScript).toHaveBeenCalledTimes(1);
|
|
expect(executeStep).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
|
expect(executeScript).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("does not bypass await-input custom graph nodes in fast mode", async () => {
|
|
const { executor } = makeExecutorForTask(task({ executionMode: "fast" }));
|
|
const awaitInput = vi.spyOn(executor as any, "runAwaitInputNode").mockResolvedValue({ outcome: "success", value: "awaiting-input" });
|
|
|
|
const result = await (executor as any).runGraphCustomNode(
|
|
{ id: "human", kind: "prompt", config: { awaitInput: true } },
|
|
task({ executionMode: "fast" }),
|
|
{},
|
|
undefined,
|
|
);
|
|
|
|
expect(result.value).toBe("awaiting-input");
|
|
expect(awaitInput).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// U4 (KTD-2): the legacy `workflow-step` seam and `runWorkflowStep` primitive
|
|
// were removed, so the two it.each blocks that drove them directly (fast-mode
|
|
// skip + standard-mode run) are gone. Fast-mode skip of workflow gates is now
|
|
// covered above by the custom-node tests ("skips custom %s nodes in fast mode")
|
|
// and by builtin-coding-workflow-step-results.test.ts (graph recording path).
|
|
|
|
it("re-enters graph recovery for fast completed tasks with unsatisfied explicit optional steps", async () => {
|
|
const liveTask = task({
|
|
id: "FN-7283-RECOVERY",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: ["browser-verification"],
|
|
worktree: "/tmp/wt",
|
|
baseCommitSha: "base",
|
|
steps: [{ name: "Do it", status: "done" }],
|
|
workflowStepResults: [],
|
|
});
|
|
const { executor } = makeExecutorForTask(liveTask);
|
|
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]);
|
|
const graph = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
|
|
|
|
const recovered = await executor.recoverCompletedTask(liveTask as any);
|
|
|
|
expect(recovered).toBe(true);
|
|
expect(graph).toHaveBeenCalledWith(liveTask);
|
|
});
|
|
|
|
it("fails closed when a fast task has explicit optional steps but the store cannot resolve workflow selection", async () => {
|
|
const liveTask = task({
|
|
id: "FN-7283-MINIMAL-STORE",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: ["browser-verification"],
|
|
worktree: "/tmp/wt",
|
|
});
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue(liveTask);
|
|
const executor = new TaskExecutor(store, "/tmp/test") as any;
|
|
const graphFailure = vi.spyOn(executor, "handleGraphFailure").mockResolvedValue(undefined);
|
|
|
|
const handled = await executor.maybeExecuteWorkflowGraph(liveTask);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(graphFailure).toHaveBeenCalledWith(liveTask, expect.objectContaining({
|
|
disposition: "failed",
|
|
outcome: "failure",
|
|
reason: expect.stringContaining("workflow-selection-api-unavailable"),
|
|
}));
|
|
});
|
|
|
|
it("skips graph recovery for fast completed tasks with no explicit optional steps", async () => {
|
|
const liveTask = task({
|
|
id: "FN-7283-RECOVERY-EMPTY",
|
|
executionMode: "fast",
|
|
enabledWorkflowSteps: [],
|
|
worktree: "/tmp/wt",
|
|
baseCommitSha: "base",
|
|
steps: [{ name: "Do it", status: "done" }],
|
|
workflowStepResults: [],
|
|
});
|
|
const { store, executor } = makeExecutorForTask(liveTask);
|
|
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]);
|
|
const graph = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
|
|
|
|
const recovered = await executor.recoverCompletedTask(liveTask as any);
|
|
|
|
expect(recovered).toBe(true);
|
|
expect(graph).not.toHaveBeenCalled();
|
|
expect(store.handoffToReview).toHaveBeenCalledWith(
|
|
"FN-7283-RECOVERY-EMPTY",
|
|
expect.objectContaining({ evidence: expect.objectContaining({ reason: "completed-task-recovered" }) }),
|
|
);
|
|
});
|
|
|
|
it("keeps fn_task_done mandatory while excluding fn_review_step in fast mode", async () => {
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf"),
|
|
branchWithSummary: vi.fn(),
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
capturedTools: opts.customTools,
|
|
}));
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue(task({ id: "FN-TOOLS", executionMode: "fast" }));
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(task({ id: "FN-TOOLS", executionMode: "fast" }));
|
|
|
|
const tools = mockedCreateFnAgent.mock.calls[0][0].customTools.map((tool: any) => tool.name);
|
|
expect(tools).toContain("fn_task_done");
|
|
expect(tools).not.toContain("fn_review_step");
|
|
});
|
|
|
|
it("includes fn_review_step in standard mode", async () => {
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf"),
|
|
branchWithSummary: vi.fn(),
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
capturedTools: opts.customTools,
|
|
}));
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue(task({ id: "FN-TOOLS", executionMode: "standard" }));
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
await executor.execute(task({ id: "FN-TOOLS", executionMode: "standard" }));
|
|
|
|
const tools = mockedCreateFnAgent.mock.calls[0][0].customTools.map((tool: any) => tool.name);
|
|
expect(tools).toContain("fn_review_step");
|
|
});
|
|
|
|
it("omits legacy fn_review_step in graph-owned standard execution sessions", async () => {
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => ({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf"),
|
|
branchWithSummary: vi.fn(),
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
capturedTools: opts.customTools,
|
|
}));
|
|
const store = createMockStore();
|
|
const liveTask = task({ id: "FN-TOOLS", executionMode: "standard" });
|
|
store.getTask.mockResolvedValue(liveTask);
|
|
const executor = new TaskExecutor(store, "/tmp/test") as any;
|
|
executor.graphCompletionInterceptors.set("FN-TOOLS", vi.fn());
|
|
|
|
await executor.execute(liveTask);
|
|
|
|
const tools = mockedCreateFnAgent.mock.calls[0][0].customTools.map((tool: any) => tool.name);
|
|
expect(tools).toContain("fn_task_done");
|
|
expect(tools).not.toContain("fn_review_step");
|
|
});
|
|
});
|