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

View File

@@ -2818,6 +2818,162 @@ describe("PlanningModeModal", () => {
}); });
expect(screen.queryByText("No active question in session")).toBeNull(); 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", () => { describe("Conversation history", () => {