feat(FN-5205): add workflow-step test mode dispatch and routing for mock pr

Implements workflow step test mode (FN-5205) by wiring mock dispatch, context forwarding, and routing through executor, merger, and mock provider, plus adding corresponding tests and docs. Also adds broad-scope triage heuristics to improve task-scope detection, touching triage.ts, triage-broad-scope

Fusion-Task-Id: FN-5205

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5205
This commit is contained in:
gsxdsm
2026-05-23 02:15:36 -07:00
parent b22112af89
commit 76bd3a7d90
19 changed files with 808 additions and 23 deletions

View File

@@ -126,6 +126,26 @@ describe("createResolvedAgentSession", () => {
}),
);
});
it("forwards workflow step runtime context fields to mock sessions", async () => {
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: undefined,
cwd: "/tmp/project",
systemPrompt: "system",
defaultProvider: "mock",
runtimeContext: {
workflowStepId: "WS-004",
workflowStepTemplateId: "browser-verification",
},
});
const mockMeta = (result.session as unknown as { __mock?: { workflowStepId?: string; workflowStepTemplateId?: string } }).__mock;
expect(mockMeta?.workflowStepId).toBe("WS-004");
expect(mockMeta?.workflowStepTemplateId).toBe("browser-verification");
});
});
describe("resolveMergerSessionModel", () => {

View File

@@ -27,6 +27,7 @@ import {
clearMockScript,
resetMockScripts,
setMockScript,
resolveMockScript,
} from "../providers/mock-provider.js";
function createTool(name: string, execute = vi.fn().mockResolvedValue({ content: [], details: {} })): ToolDefinition {
@@ -67,6 +68,7 @@ describe("MockAgentRuntime", () => {
["merger", []],
["heartbeat", []],
["validation", []],
["workflow-step", []],
] as const)("runs the default %s script deterministically", async (sessionPurpose, expectedCalls) => {
const runtime = new MockAgentRuntime();
const { cwd, taskDir, taskId } = await createWorkspace();
@@ -128,6 +130,34 @@ describe("MockAgentRuntime", () => {
if (sessionPurpose === "reviewer" || sessionPurpose === "validation") {
expect(onText).toHaveBeenCalledWith(expect.stringContaining("Verdict: APPROVE"));
}
if (sessionPurpose === "workflow-step") {
expect(onText).toHaveBeenCalledWith(expect.stringContaining('{"verdict":"APPROVE","notes":""}'));
}
});
it("resolves mock script overrides by specificity precedence", async () => {
const defaultScript = resolveMockScript({ sessionPurpose: "workflow-step" });
const purposeOnly = { run: vi.fn(async () => undefined) };
const templateOnly = { run: vi.fn(async () => undefined) };
const taskOnly = { run: vi.fn(async () => undefined) };
const taskAndTemplate = { run: vi.fn(async () => undefined) };
setMockScript({ sessionPurpose: "workflow-step" }, purposeOnly);
setMockScript({ sessionPurpose: "workflow-step", workflowStepTemplateId: "browser-verification" }, templateOnly);
setMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1" }, taskOnly);
setMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "browser-verification" }, taskAndTemplate);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "browser-verification" })).toBe(taskAndTemplate);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "other-template" })).toBe(taskOnly);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-2", workflowStepTemplateId: "browser-verification" })).toBe(templateOnly);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-2", workflowStepTemplateId: "other-template" })).toBe(purposeOnly);
expect(resolveMockScript({ sessionPurpose: "workflow-step" })).toBe(purposeOnly);
clearMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "browser-verification" });
clearMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1" });
clearMockScript({ sessionPurpose: "workflow-step", workflowStepTemplateId: "browser-verification" });
clearMockScript({ sessionPurpose: "workflow-step" });
expect(resolveMockScript({ sessionPurpose: "workflow-step" })).toBe(defaultScript);
});
it("prefers a task-scoped override over the default script", async () => {
@@ -200,7 +230,7 @@ describe("MockAgentRuntime", () => {
throw new Error("https.request should not be called");
});
for (const sessionPurpose of ["executor", "triage", "reviewer", "merger", "heartbeat", "validation"] as const) {
for (const sessionPurpose of ["executor", "triage", "reviewer", "merger", "heartbeat", "validation", "workflow-step"] as const) {
const { cwd, taskId } = await createWorkspace(`FN-${sessionPurpose}`);
const { session } = await runtime.createSession({
cwd,

View File

@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DEFAULT_SETTINGS, TaskStore } from "@fusion/core";
import * as broadScopeHeuristics from "../../triage-broad-scope-heuristics.js";
import { TriageProcessor } from "../../triage.js";
function git(cwd: string, command: string): string {
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
async function createFixture() {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-broad-scope-triage-"));
git(rootDir, "git init -b main");
git(rootDir, 'git config user.email "test@example.com"');
git(rootDir, 'git config user.name "Test User"');
git(rootDir, "git commit --allow-empty -m init");
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
await store.updateSettings({ ...DEFAULT_SETTINGS, requirePlanApproval: false });
const triage = new TriageProcessor(store, rootDir);
return {
rootDir,
store,
triage,
persistPrompt: async (taskId: string, prompt: string) => {
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), prompt, "utf-8");
},
cleanup: async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
},
};
}
function buildPrompt({ size, stepCount, fileScopeCount }: { size: "S" | "M" | "L"; stepCount: number; fileScopeCount: number }): string {
const steps = Array.from({ length: stepCount }, (_, index) => `### Step ${index + 1}: Step ${index + 1}\n- [ ] do work ${index + 1}`)
.join("\n\n");
const fileScope = Array.from({ length: fileScopeCount }, (_, index) => `- ` + "`" + `packages/engine/src/generated/file-${index + 1}.ts` + "`")
.join("\n");
return `# Task: FN-1 - test\n\n**Size:** ${size}\n\n## Review Level: 1\n\n## File Scope\n${fileScope}\n\n## Steps\n\n${steps}\n`;
}
describe("reliability interactions: broad-scope triage flag", () => {
const fixtures: Array<Awaited<ReturnType<typeof createFixture>>> = [];
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
while (fixtures.length) await fixtures.pop()!.cleanup();
});
it("adds broadScopeFlag metadata and preserves intentSignature/fileScope composition", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.sourceMetadata?.broadScopeFlag).toMatchObject({
score: 9,
reasons: expect.arrayContaining(["size-l", "steps-high", "file-scope-high", "failing-file-mentions-high", "size-l-with-many-steps"]),
signals: expect.objectContaining({
size: "L",
stepCount: 12,
fileScopeCount: 25,
failingFileMentions: 30,
}),
thresholds: expect.objectContaining({
stepsHigh: 12,
fileScopeHigh: 20,
failingFileMentionsHigh: 30,
sizeLStepsThreshold: 9,
}),
version: 1,
flaggedAt: expect.any(String),
});
expect(updated.sourceMetadata?.intentSignature).toBeTruthy();
expect(updated.sourceMetadata?.fileScope).toHaveLength(25);
});
it("keeps flagged tasks in todo because the flag is advisory only", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.column).toBe("todo");
});
it("emits a run-audit event with the broad-scope metadata payload", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
expect(audit).toEqual(expect.arrayContaining([
expect.objectContaining({
mutationType: "task:broad-scope-flagged-at-triage",
metadata: expect.objectContaining({
score: 9,
reasons: expect.arrayContaining(["size-l", "steps-high", "file-scope-high", "failing-file-mentions-high", "size-l-with-many-steps"]),
signals: expect.objectContaining({ size: "L", stepCount: 12, fileScopeCount: 25, failingFileMentions: 30 }),
thresholds: expect.objectContaining({
stepsHigh: 12,
fileScopeHigh: 20,
failingFileMentionsHigh: 30,
sizeLStepsThreshold: 9,
}),
version: 1,
}),
}),
]));
});
it("appends an operator log entry when the flag fires", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(true);
});
it("does not add flag metadata, audit, or log entry for small narrow tasks", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Fix one narrow regression",
description: "Touches auth.ts only.",
});
const prompt = buildPrompt({ size: "S", stepCount: 4, fileScopeCount: 3 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.column).toBe("todo");
expect(updated.sourceMetadata?.broadScopeFlag).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(false);
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
expect(audit.some((entry) => entry.mutationType === "task:broad-scope-flagged-at-triage")).toBe(false);
});
it("fails open when signal extraction throws", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
vi.spyOn(broadScopeHeuristics, "extractBroadScopeSignals").mockImplementation(() => {
throw new Error("boom");
});
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.column).toBe("todo");
expect(updated.sourceMetadata?.broadScopeFlag).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(false);
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
expect(audit.some((entry) => entry.mutationType === "task:broad-scope-flagged-at-triage")).toBe(false);
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
BROAD_SCOPE_FLAG_VERSION,
decideBroadScopeFlag,
extractBroadScopeSignals,
} from "../triage-broad-scope-heuristics.js";
describe("triage broad-scope heuristics", () => {
describe("extractBroadScopeSignals", () => {
it("uses the largest matching multi-digit failing-file mention", () => {
const signals = extractBroadScopeSignals({
size: "M",
stepCount: 5,
fileScopeCount: 4,
descriptionText: "Touches 12 failing files, 30 broken tests, and 21 files overall. Ignore 7 failing files.",
});
expect(signals).toMatchObject({
size: "M",
stepCount: 5,
fileScopeCount: 4,
failingFileMentions: 30,
});
});
it("caps pathological counts at 9999", () => {
const signals = extractBroadScopeSignals({
size: "L",
stepCount: 14,
fileScopeCount: 24,
descriptionText: "Spec mentions 12345 failing files across 200 broken tests.",
});
expect(signals.failingFileMentions).toBe(9999);
});
it("returns zero when there are no qualifying mentions", () => {
const signals = extractBroadScopeSignals({
size: "S",
stepCount: 2,
fileScopeCount: 1,
descriptionText: "Only 9 failing files are listed, plus one broken test.",
});
expect(signals.failingFileMentions).toBe(0);
});
});
describe("decideBroadScopeFlag", () => {
it("does not flag small low-scope tasks", () => {
const decision = decideBroadScopeFlag({
size: "S",
stepCount: 4,
fileScopeCount: 3,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(false);
expect(decision.score).toBe(0);
expect(decision.reasons).toEqual([]);
});
it("does not flag size L alone", () => {
const decision = decideBroadScopeFlag({
size: "L",
stepCount: 4,
fileScopeCount: 3,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(false);
expect(decision.score).toBe(2);
expect(decision.reasons).toEqual(["size-l"]);
});
it("flags size L tasks with many steps", () => {
const decision = decideBroadScopeFlag({
size: "L",
stepCount: 12,
fileScopeCount: 3,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(true);
expect(decision.score).toBe(5);
expect(decision.reasons).toEqual(["size-l", "steps-high", "size-l-with-many-steps"]);
});
it("does not flag high file scope alone", () => {
const decision = decideBroadScopeFlag({
size: "M",
stepCount: 5,
fileScopeCount: 21,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(false);
expect(decision.score).toBe(2);
expect(decision.reasons).toEqual(["file-scope-high"]);
});
it("flags when multiple strong signals combine", () => {
const decision = decideBroadScopeFlag({
size: "M",
stepCount: 5,
fileScopeCount: 21,
failingFileMentions: 30,
});
expect(decision.flagged).toBe(true);
expect(decision.score).toBe(4);
expect(decision.reasons).toEqual(["file-scope-high", "failing-file-mentions-high"]);
});
});
it("exports the initial heuristic version", () => {
expect(BROAD_SCOPE_FLAG_VERSION).toBe(1);
});
});

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor, parseWorkflowStepVerdict } from "../executor.js";
import { mockedCreateFnAgent, createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
function buildTask() {
return {
id: "FN-5205",
title: "Workflow test",
description: "",
column: "in-progress" as const,
dependencies: [],
steps: [{ name: "Preflight", status: "done" as const }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-004"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function buildStep() {
return {
id: "WS-004",
templateId: "browser-verification",
name: "Browser Verification",
mode: "prompt",
toolMode: "readonly",
prompt: "verify",
gateMode: "gate",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function scriptedSession(output: string) {
const subscribers: Array<(event: any) => void> = [];
return {
state: {},
subscribe: (cb: (event: any) => void) => subscribers.push(cb),
prompt: vi.fn(async () => {
subscribers.forEach((cb) => cb({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: output } }));
}),
dispose: vi.fn(),
getSessionStats: () => ({ tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }),
};
}
describe("workflow-step test mode routing", () => {
beforeEach(() => {
resetExecutorMocks();
});
it("FN-5205 test mode browser verification defaults to APPROVE", async () => {
const store = createMockStore();
const task = buildTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
vi.spyOn(TaskExecutor.prototype as any, "captureModifiedFiles").mockResolvedValue([]);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"APPROVE","notes":""}\n') as any, sessionFile: undefined } as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { testMode: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
expect(result.allPassed).toBe(true);
const args = mockedCreateFnAgent.mock.calls.at(-1)?.[0] as any;
expect(args?.defaultProvider).toBe("mock");
expect(args?.defaultModelId).toBe("scripted");
expect(args?.runtimeContext).toEqual({ workflowStepId: "WS-004", workflowStepTemplateId: "browser-verification" });
const parsed = parseWorkflowStepVerdict('{"verdict":"APPROVE","notes":""}');
expect(parsed?.verdict).toBe("APPROVE");
});
it("test mode per-template REVISE override blocks merge path", async () => {
const store = createMockStore();
const task = buildTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
vi.spyOn(TaskExecutor.prototype as any, "captureModifiedFiles").mockResolvedValue([]);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"REVISE","notes":"forced FAIL via FN-5205 override"}\n') as any, sessionFile: undefined } as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { testMode: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
expect(result).toEqual(expect.objectContaining({ allPassed: false, revisionRequested: true, stepName: "Browser Verification" }));
expect(String((result as any).feedback)).toContain("forced FAIL");
});
it("test mode off preserves real-provider selection while forwarding template context", async () => {
const store = createMockStore();
const task = buildTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"APPROVE","notes":""}\n') as any, sessionFile: undefined } as any);
await (executor as any).executeWorkflowStep(task as any, buildStep() as any, "/tmp/test", {
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}, undefined);
const args = mockedCreateFnAgent.mock.calls.at(-1)?.[0] as any;
expect(args?.defaultProvider).toBe("anthropic");
expect(args?.runtimeContext?.workflowStepTemplateId).toBe("browser-verification");
});
it("FN-5205 mock workflow-step path does not spawn browser harness or bash tools", async () => {
const store = createMockStore();
const task = buildTask();
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"APPROVE","notes":""}\n') as any, sessionFile: undefined } as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
await (executor as any).executeWorkflowStep(task as any, buildStep() as any, "/tmp/test", { testMode: true, defaultProvider: "mock", defaultModelId: "scripted" }, undefined);
const args = mockedCreateFnAgent.mock.calls.at(-1)?.[0] as any;
expect(args?.tools).toBe("readonly");
expect((args?.customTools ?? []).some((tool: { name?: string }) => {
const name = String(tool.name ?? "");
return name === "bash" || name === "Bash" || name.startsWith("agent-browser");
})).toBe(false);
});
});