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:
7
.changeset/fix-planning-ai-start.md
Normal file
7
.changeset/fix-planning-ai-start.md
Normal 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.
|
||||||
@@ -790,6 +790,63 @@ describe("Planning Mode Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /planning/start-streaming", () => {
|
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 () => {
|
it("broadcasts a reviewable initial plan without an unsolicited question", async () => {
|
||||||
const messages: Array<{ role: string; content: string }> = [];
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
const responses = [
|
const responses = [
|
||||||
|
|||||||
@@ -1723,7 +1723,15 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
awaiting-input question; only Validate writes `session.validated`, which authorizes a
|
awaiting-input question; only Validate writes `session.validated`, which authorizes a
|
||||||
terminal complete event and closes the stream.
|
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 existing = planningStreamManager.getBufferedEvents(sessionId, 0);
|
||||||
const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary");
|
const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary");
|
||||||
const summaryEventId = lastSummaryEvent?.id
|
const summaryEventId = lastSummaryEvent?.id
|
||||||
|
|||||||
Reference in New Issue
Block a user