diff --git a/.changeset/fix-planning-ai-start.md b/.changeset/fix-planning-ai-start.md new file mode 100644 index 0000000000..932107ea7d --- /dev/null +++ b/.changeset/fix-planning-ai-start.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Wait for the AI-authored Planning Mode plan before enabling review actions. +category: fix +dev: Suppresses seeded fallback summaries from SSE catch-up while a planning generation purpose remains active. diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 6db671abb7..a8a7c9be42 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -790,6 +790,63 @@ describe("Planning Mode Routes", () => { }); describe("POST /planning/start-streaming", () => { + it("does not expose the seeded fallback as a reviewable plan while the AI turn is active", async () => { + const messages: Array<{ role: string; content: string }> = []; + let releasePrompt: (() => void) | undefined; + let markPromptStarted: (() => void) | undefined; + const promptStarted = new Promise((resolve) => { + markPromptStarted = resolve; + }); + __setCreateFnAgent(async () => ({ + session: { + state: { messages }, + prompt: vi.fn(async (message: string) => { + messages.push({ role: "user", content: message }); + markPromptStarted?.(); + await new Promise((resolve) => { + releasePrompt = resolve; + }); + messages.push({ + role: "assistant", + content: JSON.stringify({ + type: "complete", + data: { + title: "AI-authored plan", + description: "Generated after repository inspection.", + proposedChanges: ["Implement the requested behavior"], + acceptanceCriteria: ["The behavior is verified"], + keyDeliverables: ["Working implementation"], + }, + }), + }); + }), + dispose: vi.fn(), + }, + })); + + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ initialPlan: "Generate this plan with AI" }), + { "Content-Type": "application/json" }, + ); + const sessionId = startRes.body.sessionId as string; + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); + + await promptStarted; + planningStreamManager.broadcast(sessionId, { type: "complete" }); + const streamRes = await streamPromise; + releasePrompt?.(); + await vi.waitFor(() => { + expect(planningStreamManager.getBufferedEvents(sessionId, 0).some((event) => event.event === "summary")).toBe(true); + }); + + expect(messages[0]?.content).toContain("Generate this plan with AI"); + expect(streamRes.body).not.toContain("event: summary"); + expect(streamRes.body).not.toContain("Generate this plan with AI"); + }); + it("broadcasts a reviewable initial plan without an unsolicited question", async () => { const messages: Array<{ role: string; content: string }> = []; const responses = [ diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index e5d71de771..dbbc4cb641 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -1723,7 +1723,15 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann awaiting-input question; only Validate writes `session.validated`, which authorizes a terminal complete event and closes the stream. */ - if (session.summary) { + /* + FNXC:PlanningMode 2026-07-20-18:05: + New and resumed sessions seed `summary` with deterministic fallback copy before the AI + turn starts. While `generationPurpose` is set, that value is working state rather than a + review-ready plan. Publishing it here moves the client out of loading early and exposes + Refine/Validate against the still-active generation. Only catch up a settled summary; + the generation path clears its purpose before broadcasting the AI-authored replacement. + */ + if (session.summary && session.generationPurpose === undefined) { const existing = planningStreamManager.getBufferedEvents(sessionId, 0); const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary"); const summaryEventId = lastSummaryEvent?.id