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:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
127
packages/engine/src/__tests__/workflow-step-test-mode.test.ts
Normal file
127
packages/engine/src/__tests__/workflow-step-test-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user