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);
|
setGenerationStartTime(null);
|
||||||
|
|
||||||
if (priorQuestion) {
|
if (priorQuestion) {
|
||||||
|
currentSessionIdRef.current = sessionId;
|
||||||
const answered = history.some((entry) => entry.question?.id === priorQuestion.id && entry.response);
|
const answered = history.some((entry) => entry.question?.id === priorQuestion.id && entry.response);
|
||||||
setEditingQuestionId(answered ? priorQuestion.id : null);
|
setEditingQuestionId(answered ? priorQuestion.id : null);
|
||||||
setWorkspaceQuestion(priorQuestion);
|
setWorkspaceQuestion(priorQuestion);
|
||||||
@@ -2404,6 +2405,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
session: { sessionId, currentQuestion: priorQuestion, summary },
|
session: { sessionId, currentQuestion: priorQuestion, summary },
|
||||||
});
|
});
|
||||||
} else if (summary) {
|
} else if (summary) {
|
||||||
|
currentSessionIdRef.current = sessionId;
|
||||||
setWorkspaceQuestion(null);
|
setWorkspaceQuestion(null);
|
||||||
setView({
|
setView({
|
||||||
type: "plan_review",
|
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.
|
may finalize during loading or recoverable error states; the server cancels an active turn safely.
|
||||||
*/
|
*/
|
||||||
const handleRefineFromPlan = useCallback(async () => {
|
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;
|
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);
|
setError(null);
|
||||||
setGenerationActivity("question");
|
setGenerationActivity("question");
|
||||||
setIsRefineMenuOpen(false);
|
setIsRefineMenuOpen(false);
|
||||||
@@ -2467,8 +2486,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} else {
|
} else {
|
||||||
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
|
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 handleProceedWithPlan = useCallback(async () => {
|
||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
@@ -2497,6 +2518,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}, [viewportMode]);
|
}, [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 () => {
|
const handleRetryCreateTask = useCallback(async () => {
|
||||||
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
||||||
validateCreateInFlightRef.current = true;
|
validateCreateInFlightRef.current = true;
|
||||||
@@ -2735,7 +2768,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
>
|
>
|
||||||
{t("common.cancel", "Cancel")}
|
{t("common.cancel", "Cancel")}
|
||||||
</button>
|
</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")}
|
{t("planning.applyRefinement", "Apply refinement")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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." } });
|
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Add migration sequencing and ask about rollout risks." } });
|
||||||
const applyButton = screen.getByRole("button", { name: "Apply refinement" });
|
const applyButton = screen.getByRole("button", { name: "Apply refinement" });
|
||||||
expect(fireEvent.pointerDown(applyButton, { pointerType: "touch" })).toBe(false);
|
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"));
|
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();
|
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));
|
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 () => {
|
it("renders exactly one write-your-own choice for normalized select questions", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({
|
mockFetchAiSession.mockResolvedValue({
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
createSessionWithAgent,
|
createSessionWithAgent,
|
||||||
getSession,
|
getSession,
|
||||||
planningStreamManager,
|
planningStreamManager,
|
||||||
|
setAiSessionStore,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
} from "../planning.js";
|
} from "../planning.js";
|
||||||
|
|
||||||
@@ -35,8 +37,15 @@ const MOCK_TASK_STORE = {
|
|||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
describe("planning generation cancellation", () => {
|
describe("planning generation cancellation", () => {
|
||||||
|
const persistSession = vi.fn(async () => {});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__resetPlanningState();
|
__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 () => {
|
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();
|
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);
|
expect(stopGeneration(sessionId)).toBe(true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
expect(promptSignal?.aborted).toBe(true);
|
expect(promptSignal?.aborted).toBe(true);
|
||||||
expect(dispose).toHaveBeenCalledTimes(1);
|
expect(dispose).toHaveBeenCalledTimes(1);
|
||||||
expect((await getSession(sessionId))?.error).toBeUndefined();
|
expect((await getSession(sessionId))?.error).toBeUndefined();
|
||||||
|
expect(persistSession).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
id: sessionId,
|
||||||
|
status: "awaiting_input",
|
||||||
|
result: expect.stringContaining("Reviewable plan"),
|
||||||
|
}));
|
||||||
|
|
||||||
resolveHungPrompt?.();
|
resolveHungPrompt?.();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|||||||
@@ -3102,8 +3102,11 @@ export function stopGeneration(sessionId: string): boolean {
|
|||||||
} else {
|
} else {
|
||||||
session.currentQuestion = undefined;
|
session.currentQuestion = undefined;
|
||||||
session.editingQuestionId = undefined;
|
session.editingQuestionId = undefined;
|
||||||
void persistSession(session, stoppedPurpose === "initial_plan" ? "draft" : "awaiting_input");
|
// A stopped initial turn with a usable running plan resumes at plan review; only a turn
|
||||||
if (session.summary && stoppedPurpose !== "initial_plan") {
|
// 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 });
|
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user