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:
gsxdsm
2026-07-21 00:55:33 -07:00
parent 36583bc1c7
commit e81e3bd687
5 changed files with 123 additions and 9 deletions

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

View File

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

View File

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

View File

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

View File

@@ -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 });
} }
} }