From 8b6dca43790aa1b7e0ce1a4db59b5a8d0441a96d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 14:58:48 -0700 Subject: [PATCH] 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) --- .../planning-e2e-plan-creation.test.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts diff --git a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts new file mode 100644 index 0000000000..c780226f2c --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts @@ -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 { + 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 { + 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; + 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); + }); +});