fix(planning): wait for AI plan before review

Keep seeded fallback summaries out of SSE catch-up while generation is active so Refine and Validate cannot race the initial AI turn.
This commit is contained in:
gsxdsm
2026-07-20 18:12:25 -07:00
parent 0a01cb164d
commit 2884bf76b1
3 changed files with 73 additions and 1 deletions

View File

@@ -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.

View File

@@ -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<void>((resolve) => {
markPromptStarted = resolve;
});
__setCreateFnAgent(async () => ({
session: {
state: { messages },
prompt: vi.fn(async (message: string) => {
messages.push({ role: "user", content: message });
markPromptStarted?.();
await new Promise<void>((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 = [

View File

@@ -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