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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-03 00:42:42 -07:00
parent 5d7fa7c6b6
commit dd8df26e5e
2 changed files with 97 additions and 2 deletions

View File

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

View File

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