Files
fusion/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts

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");
});
});