FN-7431: guard Planning Mode summary refinement

Prevent duplicate Refine Further activations from interrupting an active completed-summary refinement stream.

- Add a synchronous single-flight guard and loading state for summary refinement.
- Preserve the connected planning stream when a same-turn generation-in-progress response is reported.
- Cover rapid desktop/mobile activation and active-stream conflict behavior with regression tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .../fn-7431-planning-refine-single-flight.md       |   7 +
 .../dashboard/app/components/PlanningModeModal.tsx |  41 +++++-
 .../PlanningModeModal.planning-flow.test.tsx       | 156 +++++++++++++++++++++
 3 files changed, 201 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7431
Fusion-Task-Lineage: fc876022-da86-409e-818d-8c4798b49aa5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 08:15:03 -07:00
parent 7302d22b9d
commit 53d5bb1cba
3 changed files with 201 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Planning Mode Refine Further from getting stuck on duplicate generation.
category: fix
dev: Guards completed-summary refinement as a single-flight UI turn and preserves the active planning stream on same-refine in-progress responses.

View File

@@ -316,6 +316,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false);
const [isRefiningSummary, setIsRefiningSummary] = useState(false);
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -334,6 +335,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
/*
FNXC:PlanningMode 2026-07-02-07:56:
Refine Further is a single-flight completed-summary turn. Guard synchronously with a ref so duplicate click, touch, or keyboard activations cannot submit a second refine request or close the active stream with a generation-in-progress error before React renders the disabled state.
*/
const refineSummaryInFlightRef = useRef(false);
const draftSessionIdRef = useRef<string | null>(null);
/*
FNXC:PlanningMode 2026-07-01-00:00:
@@ -606,6 +612,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
setPlanningDepth("medium");
@@ -717,6 +725,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const normalizedQuestion = normalizeQuestionOptions(question);
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
clearPlanningDescription(projectId);
// Preserve reasoning accumulated during the loading turn as a
@@ -756,6 +766,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const normalizedSummary = normalizePlanningSummary(summary);
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
clearPlanningDescription(projectId);
// Preserve reasoning accumulated during the loading turn.
@@ -811,6 +823,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setError(null);
setView((prev) => {
if (prev.type === "question" || prev.type === "summary" || prev.type === "error") {
@@ -840,6 +854,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
currentSessionIdRef.current = null;
broadcastCompleted({ sessionId, status: "complete" });
},
@@ -862,6 +878,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setConversationHistory([]);
setResponseHistory([]);
setIsReconnecting(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setView({ type: "loading" });
try {
@@ -966,6 +984,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setConversationHistory([]);
setEditedSummary(null);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setView({ type: "loading" });
try {
@@ -1573,6 +1593,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
resetMobileViewportAfterClose();
onClose();
}, [flushDraftAndSummarize, initialPlan, onClose, projectId, resetMobileViewportAfterClose, view.type]);
@@ -1647,7 +1669,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
);
const handleRefineFurther = useCallback(async () => {
if (view.type !== "summary") {
if (view.type !== "summary" || refineSummaryInFlightRef.current) {
return;
}
@@ -1656,6 +1678,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
refineSummaryInFlightRef.current = true;
setIsRefiningSummary(true);
setError(null);
setIsRetrying(false);
setStreamingOutput("");
@@ -1666,9 +1690,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
await respondToPlanning(sessionId, { refine: true }, projectId, sessionTabId);
} catch (err) {
const message = getErrorMessage(err) || t("planning.failedRefinePlan", "Failed to refine plan");
if (/generation already in progress/i.test(message)) {
return;
}
refineSummaryInFlightRef.current = false;
setIsRefiningSummary(false);
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setError(getErrorMessage(err) || t("planning.failedRefinePlan", "Failed to refine plan"));
setError(message);
setView({ type: "summary", session, summary: editedSummary ?? summary });
}
}, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]);
@@ -1689,6 +1719,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
@@ -2376,6 +2408,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}}
isCreatingTask={isCreatingTask}
isStartingBreakdown={isStartingBreakdown}
isRefiningSummary={isRefiningSummary}
/>
)}
@@ -2811,6 +2844,7 @@ interface SummaryViewProps {
onRefine: () => void;
isCreatingTask: boolean;
isStartingBreakdown: boolean;
isRefiningSummary: boolean;
}
function SummaryView({
@@ -2829,6 +2863,7 @@ function SummaryView({
onRefine,
isCreatingTask,
isStartingBreakdown,
isRefiningSummary,
}: SummaryViewProps) {
const { t } = useTranslation("app");
const summary = normalizePlanningSummary(rawSummary);
@@ -2846,7 +2881,7 @@ function SummaryView({
const selectedPriority = normalizeTaskPriority(summary.priority);
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new";
const hasInvalidBranchSelection = isBranchNameRequired && !branchName.trim();
const isLoading = isCreatingTask || isStartingBreakdown;
const isLoading = isCreatingTask || isStartingBreakdown || isRefiningSummary;
const handleDependencyToggle = (taskId: string) => {
const newDeps = selectedDependencies.includes(taskId)

View File

@@ -2818,6 +2818,162 @@ describe("PlanningModeModal", () => {
});
expect(screen.queryByText("No active question in session")).toBeNull();
});
it.each(["desktop", "mobile"] as const)("keeps resumed Refine Further single-flight on rapid %s activation", async (viewportMode) => {
mockViewport(viewportMode);
const resumedSummary: PlanningSummary = {
title: "Populated summary for duplicate refine",
description: "Recovered summary with edited details before refine",
suggestedSize: "M",
suggestedDependencies: ["FN-001"],
keyDeliverables: ["Keep edits", "Ask follow-up"],
};
const refinedQuestion: PlanningQuestion = {
id: `q-refine-${viewportMode}`,
type: "text",
question: `What should we refine next on ${viewportMode}?`,
description: "Follow-up from the original refine stream",
};
mockFetchAiSession.mockResolvedValueOnce({
id: `session-complete-refine-${viewportMode}`,
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Recover and refine without duplicate generation" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
let streamHandlers: any;
let streamClosed = false;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(() => {
streamClosed = true;
}),
isConnected: vi.fn(() => !streamClosed),
};
});
mockRespondToPlanning.mockImplementation(async () => {
setTimeout(() => {
if (!streamClosed) {
streamHandlers?.onQuestion?.(refinedQuestion);
}
}, 10);
return { type: "question", data: refinedQuestion };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId={`session-complete-refine-${viewportMode}`}
/>
);
const refineButton = await screen.findByRole("button", { name: "Refine Further" });
fireEvent.click(refineButton);
fireEvent.click(refineButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledTimes(1);
});
expect(mockRespondToPlanning).toHaveBeenCalledWith(
`session-complete-refine-${viewportMode}`,
{ refine: true },
undefined,
expect.any(String),
);
await waitFor(() => {
expect(screen.getByText(`What should we refine next on ${viewportMode}?`)).toBeDefined();
});
expect(screen.queryByText(/generation already in progress/i)).toBeNull();
expect(screen.queryByText(/generation in progress/i)).toBeNull();
});
it("keeps the refine stream alive when the accepted turn reports generation already in progress", async () => {
const resumedSummary: PlanningSummary = {
title: "Backend conflict refine",
description: "Summary that was already accepted for refinement",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Continue stream"],
};
const refinedQuestion: PlanningQuestion = {
id: "q-refine-conflict",
type: "text",
question: "What detail should the already-running refine turn clarify?",
description: "Follow-up from the active refine generation",
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-refine-conflict",
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Refine active conflict" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
let streamHandlers: any;
let streamClosed = false;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(() => {
streamClosed = true;
}),
isConnected: vi.fn(() => !streamClosed),
};
});
mockRespondToPlanning.mockImplementationOnce(async () => {
setTimeout(() => {
if (!streamClosed) {
streamHandlers?.onQuestion?.(refinedQuestion);
}
}, 10);
throw new Error("Generation already in progress for this response");
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-complete-refine-conflict"
/>
);
fireEvent.click(await screen.findByRole("button", { name: "Refine Further" }));
await waitFor(() => {
expect(screen.getByText("What detail should the already-running refine turn clarify?")).toBeDefined();
});
expect(streamClosed).toBe(false);
expect(screen.queryByText(/generation already in progress/i)).toBeNull();
expect(screen.queryByText(/generation in progress/i)).toBeNull();
});
});
describe("Conversation history", () => {