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:
7
.changeset/fn-7431-planning-refine-single-flight.md
Normal file
7
.changeset/fn-7431-planning-refine-single-flight.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user