From dd8df26e5ede019f788c2e0df366a497746dc81b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 3 Aug 2026 00:42:42 -0700 Subject: [PATCH] FN-8756: suppress duplicate planner generation banners Suppresses harmless duplicate-response errors when a planner response is already generating. - Clear the shared error banner for duplicate response-generation conflicts after durable state recovery. - Preserve actionable response errors and cover question and generation states on desktop and mobile. Files changed: packages/dashboard/app/components/PlanningModeModal.tsx | 17 ++++ - packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx | 82 ++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8756 Fusion-Task-Lineage: 79cb1db1-e5e7-4b00-98f1-aeb34f2f588e Co-authored-by: Fusion (runfusion.ai) --- .../app/components/PlanningModeModal.tsx | 17 +++- .../PlanningModeModal.planning-flow.test.tsx | 82 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 80e7fb3543..f48120e6de 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -106,6 +106,18 @@ export function resetPlanningAutoRetryAttemptsForTests(): void { const MAX_PLANNING_CREATE_CLAIM_RETRIES = 20; +const DUPLICATE_RESPONSE_GENERATION_MESSAGE = "Generation already in progress for this response"; + +/** + * FNXC:PlanningTurnReconciliation 2026-08-03-07:27: + * The duplicate-response turn conflict means another request already owns this exact answer, + * not that the operator's plan failed. Rehydrate its durable question or generation progress + * silently; all other submission failures remain actionable in the shared error banner. + */ +function isDuplicateResponseGenerationConflict(error: unknown): boolean { + return getErrorMessage(error) === DUPLICATE_RESPONSE_GENERATION_MESSAGE; +} + function isPlanningCreateClaimConflict(error: unknown): boolean { return typeof error === "object" && error !== null @@ -2845,6 +2857,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } } catch (err) { const errorMessage = getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response"); + const isDuplicateResponseConflict = isDuplicateResponseGenerationConflict(err); /* FNXC:PlanningTurnReconciliation 2026-07-20-10:36: A rejected HTTP response is ambiguous: the server may have accepted the answer before @@ -2876,7 +2889,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat .map((entry) => entry.response) .filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response)))); setRunningSummary(summary); - setError(errorMessage); + setError(isDuplicateResponseConflict ? null : errorMessage); setWorkspaceQuestion(currentQuestion); setView({ type: "question", session: { sessionId, currentQuestion, summary } }); return; @@ -2890,7 +2903,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat runningSummaryRef.current = summary; setConversationHistory(history); setRunningSummary(summary); - setError(errorMessage); + setError(isDuplicateResponseConflict ? null : errorMessage); setView({ type: "loading" }); return; } diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 9eb0b68716..e36f1b06a1 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -845,6 +845,88 @@ describe("PlanningModeModal sequential flow", () => { fireEvent.click(screen.getByRole("button", { name: "Close history" })); }); + it.each([ + { viewport: "desktop", status: "awaiting_input", label: "a durable next question" }, + { viewport: "mobile", status: "awaiting_input", label: "a durable next question" }, + { viewport: "desktop", status: "generating", label: "generation progress" }, + { viewport: "mobile", status: "generating", label: "generation progress" }, + ] as const)("silently reconciles duplicate-response generation conflicts on $viewport with $label", async ({ viewport, status }) => { + mockViewportMode.mockReturnValue(viewport); + const submittedQuestion = { + id: "q-submitted", + type: "single_select", + question: "Which outcome matters most?", + options: [{ id: "secure", label: "Secure defaults" }], + }; + const durableQuestion = { + id: "q-durable", + type: "text", + question: "What should the durable session ask next?", + }; + let requestWasRejected = false; + mockFetchAiSession.mockImplementation(async () => ({ + ...base, + status: requestWasRejected ? status : "awaiting_input", + currentQuestion: requestWasRejected && status === "generating" + ? null + : JSON.stringify(requestWasRejected ? durableQuestion : submittedQuestion), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: "[]", + inputPayload: JSON.stringify({ generationPurpose: "plan_update" }), + })); + mockRespondToPlanning.mockImplementation(async () => { + requestWasRejected = true; + throw new Error("Generation already in progress for this response"); + }); + + renderSession(); + fireEvent.click(await screen.findByLabelText("Secure defaults")); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + if (status === "awaiting_input") { + expect(await screen.findByText("What should the durable session ask next?")).toBeInTheDocument(); + } else { + expect(await screen.findByText("Generating plan…")).toBeInTheDocument(); + } + expect(screen.queryByText("Generation already in progress for this response")).toBeNull(); + expect(document.querySelector(".planning-error")).toBeNull(); + }); + + it("retains an actionable response error after durable question reconciliation", async () => { + const submittedQuestion = { + id: "q-submitted", + type: "single_select", + question: "Which outcome matters most?", + options: [{ id: "secure", label: "Secure defaults" }], + }; + const durableQuestion = { + id: "q-durable", + type: "text", + question: "What should the durable session ask next?", + }; + let requestWasRejected = false; + mockFetchAiSession.mockImplementation(async () => ({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify(requestWasRejected ? durableQuestion : submittedQuestion), + result: JSON.stringify(summaryWithRefinements), + conversationHistory: "[]", + inputPayload: "{}", + })); + mockRespondToPlanning.mockImplementation(async () => { + requestWasRejected = true; + throw new Error("Response submission timed out"); + }); + + renderSession(); + fireEvent.click(await screen.findByLabelText("Secure defaults")); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + expect(await screen.findByText("What should the durable session ask next?")).toBeInTheDocument(); + expect(screen.getByText("Response submission timed out")).toBeInTheDocument(); + expect(document.querySelector(".planning-error")).toBeInTheDocument(); + }); + it("keeps both panes visible under a generating-plan overlay after Next", async () => { mockFetchAiSession.mockResolvedValue({ ...base,