FN-8343: add Planning Mode E2E coverage

Add deterministic end-to-end coverage for the Planning Mode plan-creation lifecycle.

- Exercise contextual interview responses, multilingual options, custom input, and answer rewinds
- Ensure premature agent completion cannot finalize a plan before explicit validation
- Verify validation gates task creation and session cleanup

Files changed:
 .../__tests__/planning-e2e-plan-creation.test.ts   | 286 +++++++++++++++++++++
 1 file changed, 286 insertions(+)

Fusion-Task-Id: FN-8343

Fusion-Task-Lineage: 61b8b22e-7d13-4578-ba35-0f2208fcb851

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 14:58:48 -07:00
parent da1e445d6b
commit 8b6dca4379

View File

@@ -0,0 +1,286 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
resolveMcpServersForStore: async () => ({ servers: [] }),
buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }),
createFnAgent: vi.fn(),
createAgentTask: vi.fn(),
createWorkflowAuthoringTools: () => [],
createChatTaskDocumentTools: () => [],
createChatTaskLogsReadTool: () => ({}),
}));
import { request as performRequest } from "../test-request.js";
import {
__resetPlanningState,
__setCreateFnAgent,
getSession,
} from "../planning.js";
import { registerPlanningSubtaskRoutes } from "../routes/register-planning-subtask-routes.js";
/*
FNXC:PlanningMode 2026-07-30-12:00:
FN-8343 requires one deterministic route-and-engine test for the user-controlled Planning Mode lifecycle.
The scripted agent deliberately emits a legacy complete payload during the interview: it must be coerced into another question, while only the explicit validate route may finalize the running plan.
*/
type ApiResponse = { status: number; body: any };
async function post(app: express.Express, path: string, body: unknown): Promise<ApiResponse> {
const response = await performRequest(
app,
"POST",
path,
JSON.stringify(body),
{ "Content-Type": "application/json" },
);
return { status: response.status, body: response.body };
}
async function get(app: express.Express, path: string): Promise<ApiResponse> {
const response = await performRequest(app, "GET", path);
return { status: response.status, body: response.body };
}
function question(id: string, prompt: string, language: "en" | "es" = "en") {
const spanish = language === "es";
return JSON.stringify({
type: "question",
data: {
id,
type: "single_select",
question: prompt,
options: [
{
id: `${id}-deliberate`,
label: spanish ? "Implementación gradual" : "Deliberate rollout",
pros: [spanish ? "Reduce el riesgo" : "Reduces risk"],
cons: [spanish ? "Requiere más coordinación" : "Needs coordination"],
},
{
id: `${id}-fast`,
label: spanish ? "Entrega rápida" : "Fast delivery",
pros: [spanish ? "Aporta valor antes" : "Delivers value sooner"],
cons: [spanish ? "Aumenta el riesgo" : "Increases risk"],
},
{ id: "other", label: spanish ? "Otro (escribe tu respuesta)" : "Other (write your own)", isOther: true },
],
},
});
}
function installContextAwareAgent() {
const prompts: string[] = [];
let completeResponseCount = 0;
const messages: Array<{ role: string; content: string }> = [];
const mockAgent = {
session: {
state: { messages },
prompt: vi.fn(async (message: string) => {
prompts.push(message);
messages.push({ role: "user", content: message });
let response: string;
if (/Quiero crear/i.test(message)) {
response = question("alcance", "¿Qué resultado necesita primero?", "es");
} else if (prompts.length === 1) {
response = question("scope", "Which outcome matters most?");
} else if (/priorizar controles de privacidad/i.test(message)) {
response = question("privacidad", "¿Qué controles de privacidad deben verificarse?", "en");
} else if (/An earlier answer was edited/i.test(message)) {
response = question("riesgo-reeditado", "¿Qué riesgo queda después de cambiar el alcance?");
} else if (/Selected:\s*secure\b/i.test(message)) {
// Adversarial legacy output: reactive Planning Mode must not terminate here.
completeResponseCount += 1;
response = JSON.stringify({ type: "complete", data: { title: "Premature plan", description: "Must not finalize" } });
} else {
response = question(`turn-${prompts.length}`, "Which delivery constraint matters next?");
}
messages.push({ role: "assistant", content: response });
}),
dispose: vi.fn(),
},
};
__setCreateFnAgent(async () => mockAgent as never);
return { prompts, mockAgent, getCompleteResponseCount: () => completeResponseCount };
}
function createStore() {
const createTask = vi.fn(async (input: { title: string; description: string }) => ({
id: "FN-E2E-001",
title: input.title,
description: input.description,
}));
return {
getSettings: vi.fn().mockResolvedValue({
autoMerge: false,
agentClarificationEnabled: false,
ntfyEnabled: false,
}),
getRootDir: vi.fn().mockReturnValue("/tmp/planning-e2e"),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(async () => { throw new Error("not found"); }),
createTask,
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore & { createTask: typeof createTask };
}
function buildApp(store: TaskStore): express.Express {
const app = express();
const router = express.Router();
app.use(express.json());
registerPlanningSubtaskRoutes({
router,
getProjectContext: async () => ({
store,
projectId: "planning-e2e-project",
engine: { getMessageStore: () => ({}) },
}),
planningLogger: { warn: vi.fn() },
rethrowAsApiError: (error: unknown) => { throw error; },
} as never, {
store,
parseLastEventId: () => undefined,
replayBufferedSSE: () => true,
});
app.use("/api", router);
app.use((error: { status?: number; statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.statusCode ?? error.status ?? 500).json({ error: error.message });
});
return app;
}
function expectRunningPlan(body: any) {
expect(body.summary).toEqual(expect.objectContaining({ title: expect.any(String), description: expect.any(String) }));
expect(body.validated).toBe(false);
}
function expectOpenQuestion(body: any) {
expect(body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
expect(body.data.id).not.toBe("complete");
expect(body.data.id).not.toBe("planning-deepen-checkpoint");
}
/*
FNXC:PlanningMode 2026-07-30-14:20:
The synchronous respond route intentionally returns only the next question. The SSE snapshot is
therefore the route-visible running-plan contract for every interview turn; do not replace these
assertions with internal session-state checks, which would miss a client-facing regression.
*/
async function getRunningPlan(app: express.Express, sessionId: string) {
const stream = await get(app, `/api/planning/${sessionId}/stream`);
expect(stream.status).toBe(200);
expect(typeof stream.body).toBe("string");
const match = stream.body.match(/event: summary\ndata: (.+)\n/);
expect(match?.[1]).toBeDefined();
return JSON.parse(match![1]);
}
describe("Planning Mode plan creation E2E", () => {
let store: ReturnType<typeof createStore>;
let app: express.Express;
let prompts: string[];
let getCompleteResponseCount: () => number;
beforeEach(() => {
__resetPlanningState();
store = createStore();
app = buildApp(store);
({ prompts, getCompleteResponseCount } = installContextAwareAgent());
});
afterEach(() => {
__resetPlanningState();
__setCreateFnAgent(undefined as never);
});
it("keeps a context-aware interview open until validation, then creates and releases the task", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" });
expect(start.status).toBe(201);
expect(start.body.firstQuestion).toEqual(expect.objectContaining({ id: "scope", type: "single_select" }));
expect(start.body.firstQuestion.options).toEqual(expect.arrayContaining([
expect.objectContaining({ pros: expect.any(Array), cons: expect.any(Array) }),
expect.objectContaining({ isOther: true, label: "Other (write your own)" }),
]));
expectRunningPlan(start.body);
const sessionId = start.body.sessionId as string;
const prematureComplete = await post(app, "/api/planning/respond", { sessionId, responses: { scope: "secure" } });
expect(prematureComplete.status).toBe(200);
expectOpenQuestion(prematureComplete.body);
expect(getCompleteResponseCount()).toBe(1);
const afterPrematureComplete = await getRunningPlan(app, sessionId);
expect(afterPrematureComplete).toMatchObject({ title: "Build secure account recovery" });
expect(afterPrematureComplete.description).toContain('"scope":"secure"');
const midInterview = await post(app, "/api/planning/respond", {
sessionId,
responses: { [prematureComplete.body.data.id]: "controlled" },
});
expect(midInterview.status).toBe(200);
expectOpenQuestion(midInterview.body);
expect(midInterview.body.data.id).not.toBe(prematureComplete.body.data.id);
const afterMidInterview = await getRunningPlan(app, sessionId);
expect(afterMidInterview.description).toContain('"scope":"secure"');
expect(afterMidInterview.description).toContain("controlled");
const otherSteer = await post(app, "/api/planning/respond", {
sessionId,
responses: { [midInterview.body.data.id]: "other", _other: "priorizar controles de privacidad" },
});
expect(otherSteer.status).toBe(200);
expectOpenQuestion(otherSteer.body);
expect(otherSteer.body.data).toMatchObject({ id: "privacidad", question: expect.stringContaining("privacidad") });
expect(prompts.at(-1)).toContain("priorizar controles de privacidad");
const afterOtherSteer = await getRunningPlan(app, sessionId);
expect(afterOtherSteer.description).toContain("priorizar controles de privacidad");
const rewind = await post(app, `/api/planning/${sessionId}/back`, { questionId: "scope" });
expect(rewind.status).toBe(200);
expect(rewind.body.currentQuestion).toEqual(expect.objectContaining({ id: "scope" }));
expect(rewind.body.history).toEqual(expect.arrayContaining([
expect.objectContaining({ question: expect.objectContaining({ id: "scope" }), response: { scope: "secure" } }),
expect.objectContaining({ question: expect.objectContaining({ id: prematureComplete.body.data.id }), response: { [prematureComplete.body.data.id]: "controlled" } }),
expect.objectContaining({ question: expect.objectContaining({ id: midInterview.body.data.id }), response: { [midInterview.body.data.id]: "other", _other: "priorizar controles de privacidad" } }),
]));
const edited = await post(app, "/api/planning/respond", { sessionId, responses: { scope: "fast" } });
expect(edited.status).toBe(200);
expectOpenQuestion(edited.body);
expect(edited.body.data).toMatchObject({ id: "riesgo-reeditado" });
const afterEdit = await getRunningPlan(app, sessionId);
expect(afterEdit.description).toContain('"scope":"fast"');
expect(afterEdit.description).not.toContain('"scope":"secure"');
expect(afterEdit.description).toContain("controlled");
expect(afterEdit.description).toContain("priorizar controles de privacidad");
const createBeforeValidation = await post(app, "/api/planning/create-task", { sessionId });
expect(createBeforeValidation.status).toBe(400);
const validate = await post(app, `/api/planning/${sessionId}/validate`, {});
expect(validate).toMatchObject({ status: 200, body: { validated: true } });
expect(validate.body.summary.description).toContain("fast");
expect((await getSession(sessionId))?.validated).toBe(true);
const created = await post(app, "/api/planning/create-task", { sessionId });
expect(created).toMatchObject({ status: 201, body: { id: "FN-E2E-001", title: "Build secure account recovery" } });
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ description: expect.stringContaining("fast") }));
expect(await getSession(sessionId)).toBeUndefined();
});
it("keeps AI-authored options and Other in the input language", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Quiero crear una recuperación segura de cuentas" });
expect(start.status).toBe(201);
expect(start.body.firstQuestion.question).toContain("resultado");
expect(start.body.firstQuestion.options).toEqual(expect.arrayContaining([
expect.objectContaining({ label: "Implementación gradual" }),
expect.objectContaining({ label: "Otro (escribe tu respuesta)", isOther: true }),
]));
expectRunningPlan(start.body);
});
});