fix(planning): make refinement actions reliable
Preserve stopped plans as reviewable sessions, replace active generations safely, and submit mobile refinement on the initiating touch.
This commit is contained in:
7
.changeset/fix-planning-refinement-action.md
Normal file
7
.changeset/fix-planning-refinement-action.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make plan refinement submit reliably from stopped, active, restored, and mobile planning states.
|
||||
category: fix
|
||||
dev: Uses the visible session identity, stops conflicting turns, and submits mobile refinement on pointer-down.
|
||||
@@ -2396,6 +2396,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setGenerationStartTime(null);
|
||||
|
||||
if (priorQuestion) {
|
||||
currentSessionIdRef.current = sessionId;
|
||||
const answered = history.some((entry) => entry.question?.id === priorQuestion.id && entry.response);
|
||||
setEditingQuestionId(answered ? priorQuestion.id : null);
|
||||
setWorkspaceQuestion(priorQuestion);
|
||||
@@ -2404,6 +2405,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
session: { sessionId, currentQuestion: priorQuestion, summary },
|
||||
});
|
||||
} else if (summary) {
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setWorkspaceQuestion(null);
|
||||
setView({
|
||||
type: "plan_review",
|
||||
@@ -2432,9 +2434,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
may finalize during loading or recoverable error states; the server cancels an active turn safely.
|
||||
*/
|
||||
const handleRefineFromPlan = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
const visibleSessionId = "session" in view ? view.session.sessionId : undefined;
|
||||
const sessionId = currentSessionIdRef.current ?? visibleSessionId;
|
||||
const summary = runningSummaryRef.current;
|
||||
if (!sessionId || !summary || !refinementInstructions) return;
|
||||
if (!sessionId || !summary || !refinementInstructions || refineSummaryInFlightRef.current) return;
|
||||
refineSummaryInFlightRef.current = true;
|
||||
if (view.type === "loading") {
|
||||
streamConnectionEpochRef.current += 1;
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
try {
|
||||
await stopPlanningGeneration(sessionId, projectId);
|
||||
} catch {
|
||||
// The turn may have settled between opening the refinement input and applying it.
|
||||
}
|
||||
}
|
||||
currentSessionIdRef.current = sessionId;
|
||||
liveGenerationSessionIdRef.current = sessionId;
|
||||
if (!streamConnectionRef.current?.isConnected()) {
|
||||
connectToPlanningStream(sessionId);
|
||||
}
|
||||
setError(null);
|
||||
setGenerationActivity("question");
|
||||
setIsRefineMenuOpen(false);
|
||||
@@ -2467,8 +2486,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} else {
|
||||
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
|
||||
}
|
||||
} finally {
|
||||
refineSummaryInFlightRef.current = false;
|
||||
}
|
||||
}, [projectId, refinementInstructions, t, workspaceQuestion]);
|
||||
}, [connectToPlanningStream, projectId, refinementInstructions, t, view, workspaceQuestion]);
|
||||
|
||||
const handleProceedWithPlan = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
@@ -2497,6 +2518,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
event.preventDefault();
|
||||
}, [viewportMode]);
|
||||
|
||||
const handleApplyRefinementPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (viewportMode !== "mobile" || event.pointerType === "mouse") return;
|
||||
/*
|
||||
FNXC:PlanningModeMobile 2026-07-21-00:50:
|
||||
Preventing the touch pointer default suppresses the browser's compatibility click. Apply
|
||||
before the keyboard resize can move/remove the popup button; the single-flight guard above
|
||||
makes a browser that still emits click harmless.
|
||||
*/
|
||||
event.preventDefault();
|
||||
void handleRefineFromPlan();
|
||||
}, [handleRefineFromPlan, viewportMode]);
|
||||
|
||||
const handleRetryCreateTask = useCallback(async () => {
|
||||
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
||||
validateCreateInFlightRef.current = true;
|
||||
@@ -2735,7 +2768,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!refinementInstructions} onPointerDown={handleMobileKeyboardActionPointerDown} onClick={() => void handleRefineFromPlan()}>
|
||||
<button type="button" className="btn btn-primary" disabled={!refinementInstructions} onPointerDown={handleApplyRefinementPointerDown} onClick={() => void handleRefineFromPlan()}>
|
||||
{t("planning.applyRefinement", "Apply refinement")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -344,9 +344,6 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Add migration sequencing and ask about rollout risks." } });
|
||||
const applyButton = screen.getByRole("button", { name: "Apply refinement" });
|
||||
expect(fireEvent.pointerDown(applyButton, { pointerType: "touch" })).toBe(false);
|
||||
expect(applyButton).toBeInTheDocument();
|
||||
expect(mockRespondToPlanning).not.toHaveBeenCalled();
|
||||
fireEvent.click(applyButton);
|
||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Add migration sequencing and ask about rollout risks." }, "project-1"));
|
||||
expect(await screen.findByText("Which migration risk should come first?")).toBeInTheDocument();
|
||||
});
|
||||
@@ -434,6 +431,58 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
|
||||
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
it("can refine a stopped initial plan into the first question", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "generating",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "initial_plan", generationStartedAt: new Date().toISOString() }),
|
||||
});
|
||||
mockRespondToPlanning.mockResolvedValue({
|
||||
currentQuestion: { id: "q-refined", type: "text", question: "Which refined area comes first?" },
|
||||
summary: summaryWithRefinements,
|
||||
});
|
||||
renderSession({});
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Focus the next questions on rollout." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply refinement" }));
|
||||
|
||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
{ refine: true, focus: "Focus the next questions on rollout." },
|
||||
"project-1",
|
||||
));
|
||||
expect(await screen.findByText("Which refined area comes first?")).toBeInTheDocument();
|
||||
});
|
||||
it("replaces an active generation when refinement is applied", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "generating",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }),
|
||||
});
|
||||
mockRespondToPlanning.mockResolvedValue({
|
||||
currentQuestion: { id: "q-replaced", type: "text", question: "What should the replacement prioritize?" },
|
||||
summary: summaryWithRefinements,
|
||||
});
|
||||
renderSession({});
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Replace the current direction." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply refinement" }));
|
||||
|
||||
await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
{ refine: true, focus: "Replace the current direction." },
|
||||
"project-1",
|
||||
));
|
||||
expect(await screen.findByText("What should the replacement prioritize?")).toBeInTheDocument();
|
||||
});
|
||||
it("renders exactly one write-your-own choice for normalized select questions", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
createSessionWithAgent,
|
||||
getSession,
|
||||
planningStreamManager,
|
||||
setAiSessionStore,
|
||||
stopGeneration,
|
||||
} from "../planning.js";
|
||||
|
||||
@@ -35,8 +37,15 @@ const MOCK_TASK_STORE = {
|
||||
} as unknown as TaskStore;
|
||||
|
||||
describe("planning generation cancellation", () => {
|
||||
const persistSession = vi.fn(async () => {});
|
||||
|
||||
beforeEach(() => {
|
||||
__resetPlanningState();
|
||||
persistSession.mockClear();
|
||||
setAiSessionStore(Object.assign(new EventEmitter(), {
|
||||
upsert: persistSession,
|
||||
get: vi.fn(async () => null),
|
||||
}) as any);
|
||||
});
|
||||
|
||||
it("forwards AbortSignal and disposes the in-flight planning prompt on user stop", async () => {
|
||||
@@ -70,12 +79,25 @@ describe("planning generation cancellation", () => {
|
||||
}
|
||||
expect(promptSignal).toBeDefined();
|
||||
|
||||
const activeSession = await getSession(sessionId);
|
||||
activeSession!.summary = {
|
||||
title: "Reviewable plan",
|
||||
description: "A partial plan that remains useful after stopping.",
|
||||
suggestedSize: "M",
|
||||
keyDeliverables: ["Resume refinement"],
|
||||
};
|
||||
|
||||
expect(stopGeneration(sessionId)).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(promptSignal?.aborted).toBe(true);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect((await getSession(sessionId))?.error).toBeUndefined();
|
||||
expect(persistSession).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
id: sessionId,
|
||||
status: "awaiting_input",
|
||||
result: expect.stringContaining("Reviewable plan"),
|
||||
}));
|
||||
|
||||
resolveHungPrompt?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -3102,8 +3102,11 @@ export function stopGeneration(sessionId: string): boolean {
|
||||
} else {
|
||||
session.currentQuestion = undefined;
|
||||
session.editingQuestionId = undefined;
|
||||
void persistSession(session, stoppedPurpose === "initial_plan" ? "draft" : "awaiting_input");
|
||||
if (session.summary && stoppedPurpose !== "initial_plan") {
|
||||
// A stopped initial turn with a usable running plan resumes at plan review; only a turn
|
||||
// stopped before any plan exists returns to the initial draft editor.
|
||||
const hasReviewablePlan = Boolean(session.summary);
|
||||
void persistSession(session, hasReviewablePlan || stoppedPurpose !== "initial_plan" ? "awaiting_input" : "draft");
|
||||
if (session.summary) {
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user