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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user