fix(planning): retry interrupted sessions on return
Recover persisted, SSE, and polled stream failures through the existing bounded retry path while preventing stale sessions from taking ownership.
This commit is contained in:
7
.changeset/retry-returned-planning-streams.md
Normal file
7
.changeset/retry-returned-planning-streams.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Automatically retry interrupted Planning sessions when operators return to them.
|
||||
category: fix
|
||||
dev: Uses session-scoped retry ownership across persisted, polled, and SSE error recovery.
|
||||
@@ -453,20 +453,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
*/
|
||||
const startPlanningInFlightRef = useRef(false);
|
||||
/*
|
||||
FNXC:PlanningRetry 2026-07-15-00:00:
|
||||
FN-8332 permits automatic retry only for a generation this mounted Planning
|
||||
Mode instance started. A reloaded session may reconnect to observe a server
|
||||
turn, but its persisted error must stay manual instead of spending another
|
||||
generation.
|
||||
*/
|
||||
const liveGenerationSessionIdRef = useRef<string | null>(null);
|
||||
/*
|
||||
FNXC:PlanningRetry 2026-07-13-00:00:
|
||||
FN-7946 requires stuck or terminal Planning Mode generation errors to auto-retry at most three times before the permanent error view appears. Keep the budget in refs for async SSE/poll/loadSession handlers, mirror the current attempt in state for the visible "Retrying" loading message, and reset the budget when successful progress reaches question or summary.
|
||||
FNXC:PlanningRetry 2026-07-21-10:00:
|
||||
Persisted, polled, and SSE-reported Planning stream errors share one bounded retry path. Key
|
||||
in-flight ownership to the session and invocation token so a stale load/retry cannot clear or
|
||||
mutate the newly selected session, while successful progress still resets the three-attempt budget.
|
||||
*/
|
||||
const planningAutoRetryAttemptRef = useRef(0);
|
||||
const planningAutoRetryInFlightRef = useRef(false);
|
||||
const startPlanningAutoRetryRef = useRef<(sessionId: string, errorMessage: string) => Promise<boolean>>(async () => false);
|
||||
const planningAutoRetryOwnerRef = useRef<{ sessionId: string; token: symbol } | null>(null);
|
||||
const startPlanningAutoRetryRef = useRef<(sessionId: string) => Promise<boolean>>(async () => false);
|
||||
const planningSessionLoadEpochRef = useRef(0);
|
||||
/*
|
||||
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.
|
||||
@@ -519,12 +514,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
};
|
||||
}, [isOpen, newSessionFocusSignal]);
|
||||
|
||||
const resetPlanningAutoRetryBudget = useCallback(() => {
|
||||
const resetPlanningAutoRetryAttempts = useCallback(() => {
|
||||
planningAutoRetryAttemptRef.current = 0;
|
||||
planningAutoRetryInFlightRef.current = false;
|
||||
setAutoRetryAttempt(0);
|
||||
setIsAutoRetrying(false);
|
||||
}, []);
|
||||
|
||||
const resetPlanningAutoRetryBudget = useCallback(() => {
|
||||
resetPlanningAutoRetryAttempts();
|
||||
planningAutoRetryOwnerRef.current = null;
|
||||
}, [resetPlanningAutoRetryAttempts]);
|
||||
/*
|
||||
FNXC:PlanningMultiTab 2026-07-14-00:00:
|
||||
Planning Mode has no cross-tab coordination. The persisted session row is the single source
|
||||
@@ -915,8 +914,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setStreamingOutput("");
|
||||
} else if (session.status === "error") {
|
||||
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
|
||||
const handled = liveGenerationSessionIdRef.current === sessionId
|
||||
&& await startPlanningAutoRetryRef.current(sessionId, errorMessage);
|
||||
const handled = await startPlanningAutoRetryRef.current(sessionId);
|
||||
if (handled) return;
|
||||
if (cancelled || currentSessionIdRef.current !== sessionId) return;
|
||||
/*
|
||||
@@ -982,6 +980,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setPlanningModelId(undefined);
|
||||
setPlanningThinkingLevel("");
|
||||
currentSessionIdRef.current = null;
|
||||
planningSessionLoadEpochRef.current += 1;
|
||||
startPlanningInFlightRef.current = false;
|
||||
}, [resetPlanningAutoRetryBudget]);
|
||||
|
||||
@@ -1177,16 +1176,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (isStaleEvent()) return;
|
||||
|
||||
/*
|
||||
FNXC:PlanningRetry 2026-07-15-00:00:
|
||||
FN-8332 limits the stuck-turn retry budget to generations started by
|
||||
this mounted UI. A resumed stream may observe a terminal persisted
|
||||
error, but it must surface the manual Retry/Dismiss panel instead;
|
||||
overlapping live SSE and poll signals still share the single-flight guard.
|
||||
FNXC:PlanningRetry 2026-07-21-10:00:
|
||||
A stream failure is recoverable regardless of which mount started the generation.
|
||||
Returning to Planning must use the same bounded, single-flight retry path as a live
|
||||
turn so tab suspension or navigation never turns a resumable session into an error UI.
|
||||
*/
|
||||
if (
|
||||
liveGenerationSessionIdRef.current === sessionId
|
||||
&& await startPlanningAutoRetryRef.current(sessionId, errorMessage)
|
||||
) {
|
||||
if (await startPlanningAutoRetryRef.current(sessionId)) {
|
||||
return;
|
||||
}
|
||||
setIsRetrying(false);
|
||||
@@ -1224,21 +1219,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
);
|
||||
|
||||
const startPlanningRetry = useCallback(
|
||||
async (retryTarget: { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null }, options: { auto: boolean }) => {
|
||||
async (
|
||||
retryTarget: { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null },
|
||||
options: { auto: boolean; retryToken?: symbol },
|
||||
) => {
|
||||
setError(null);
|
||||
setIsRetrying(!options.auto);
|
||||
setIsAutoRetrying(options.auto);
|
||||
setStreamingOutput("");
|
||||
setGenerationStartTime(Date.now());
|
||||
viewRef.current = { type: "loading" };
|
||||
setView({ type: "loading" });
|
||||
|
||||
currentSessionIdRef.current = retryTarget.sessionId;
|
||||
liveGenerationSessionIdRef.current = retryTarget.sessionId;
|
||||
connectToPlanningStream(retryTarget.sessionId);
|
||||
|
||||
try {
|
||||
await retryPlanningSession(retryTarget.sessionId, projectId);
|
||||
} catch (err) {
|
||||
const retryStillOwnsSession = () => currentSessionIdRef.current === retryTarget.sessionId
|
||||
&& (!options.auto || planningAutoRetryOwnerRef.current?.token === options.retryToken);
|
||||
if (!retryStillOwnsSession()) return;
|
||||
|
||||
let retryError: unknown = err;
|
||||
const retryErrorMessage = getErrorMessage(err) || "";
|
||||
|
||||
@@ -1248,6 +1250,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (!session) {
|
||||
throw new Error("Failed to refresh planning session.");
|
||||
}
|
||||
if (!retryStillOwnsSession()) return;
|
||||
|
||||
currentSessionIdRef.current = session.id;
|
||||
|
||||
@@ -1301,11 +1304,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
});
|
||||
}
|
||||
} else if (session.status === "error") {
|
||||
setView({
|
||||
const terminalView: ViewState = {
|
||||
type: "error",
|
||||
session: { sessionId: session.id, currentQuestion: null, summary: null },
|
||||
errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."),
|
||||
});
|
||||
};
|
||||
viewRef.current = terminalView;
|
||||
setView(terminalView);
|
||||
setIsAutoRetrying(false);
|
||||
}
|
||||
|
||||
@@ -1315,45 +1320,60 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}
|
||||
|
||||
if (!retryStillOwnsSession()) return;
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setView({
|
||||
if (options.auto && planningAutoRetryAttemptRef.current < MAX_PLANNING_AUTO_RETRIES) {
|
||||
viewRef.current = { type: "loading" };
|
||||
setView({ type: "loading" });
|
||||
setIsAutoRetrying(true);
|
||||
queueMicrotask(() => {
|
||||
if (currentSessionIdRef.current === retryTarget.sessionId) {
|
||||
void startPlanningAutoRetryRef.current(retryTarget.sessionId);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const terminalView: ViewState = {
|
||||
type: "error",
|
||||
session: retryTarget,
|
||||
errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."),
|
||||
});
|
||||
};
|
||||
viewRef.current = terminalView;
|
||||
setView(terminalView);
|
||||
setIsAutoRetrying(false);
|
||||
} finally {
|
||||
if (!options.auto) {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
planningAutoRetryInFlightRef.current = false;
|
||||
if (options.retryToken && planningAutoRetryOwnerRef.current?.token === options.retryToken) {
|
||||
planningAutoRetryOwnerRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t],
|
||||
);
|
||||
|
||||
const startPlanningAutoRetry = useCallback(
|
||||
async (sessionId: string, _errorMessage: string) => {
|
||||
if (viewRef.current.type === "error") {
|
||||
return false;
|
||||
}
|
||||
if (planningAutoRetryInFlightRef.current) {
|
||||
async (sessionId: string) => {
|
||||
if (planningAutoRetryOwnerRef.current?.sessionId === sessionId) {
|
||||
return true;
|
||||
}
|
||||
if (viewRef.current.type === "error") return false;
|
||||
if (planningAutoRetryAttemptRef.current >= MAX_PLANNING_AUTO_RETRIES) {
|
||||
setIsAutoRetrying(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const attempt = planningAutoRetryAttemptRef.current + 1;
|
||||
const retryToken = Symbol(`planning-auto-retry:${sessionId}:${attempt}`);
|
||||
planningAutoRetryAttemptRef.current = attempt;
|
||||
planningAutoRetryInFlightRef.current = true;
|
||||
planningAutoRetryOwnerRef.current = { sessionId, token: retryToken };
|
||||
setAutoRetryAttempt(attempt);
|
||||
setIsAutoRetrying(true);
|
||||
await startPlanningRetry(
|
||||
{ sessionId, currentQuestion: null, summary: null },
|
||||
{ auto: true },
|
||||
{ auto: true, retryToken },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
@@ -1426,7 +1446,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
);
|
||||
draftSessionIdRef.current = null;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
liveGenerationSessionIdRef.current = sessionId;
|
||||
setSelectedSessionId(sessionId);
|
||||
setShowSessionList(false);
|
||||
setMobileShowDetail(true);
|
||||
@@ -1497,10 +1516,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
// Load a specific persisted session into the right pane.
|
||||
const loadSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
const loadEpoch = ++planningSessionLoadEpochRef.current;
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
// Loading a database row never makes its in-flight turn local to this mount.
|
||||
liveGenerationSessionIdRef.current = null;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
setError(null);
|
||||
setStreamingOutput("");
|
||||
@@ -1514,10 +1533,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setIsRefiningSummary(false);
|
||||
refineSummaryInFlightRef.current = false;
|
||||
setGenerationStartTime(null);
|
||||
viewRef.current = { type: "loading" };
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const session = await fetchAiSession(sessionId);
|
||||
if (planningSessionLoadEpochRef.current !== loadEpoch || currentSessionIdRef.current !== sessionId) return;
|
||||
if (!session) {
|
||||
// The session was deleted (commonly: this tab just turned it into
|
||||
// tasks via Create Task / Create Tasks). Quietly fall back to the
|
||||
@@ -1596,16 +1617,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
|
||||
if (session.status === "error") {
|
||||
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
|
||||
/*
|
||||
FNXC:PlanningRetry 2026-07-15-00:00:
|
||||
FN-8332 requires browser-reload/session-resume to render the durable planning state verbatim and never dispatch a new generation. Auto-retry remains exclusively for live in-session SSE and loading-poll failures; persisted errors must expose the manual Retry/Dismiss panel.
|
||||
FNXC:PlanningRetry 2026-07-21-10:00:
|
||||
Persisted stream errors are retryable work, not a terminal Planning destination. Re-enter
|
||||
through the same generation retry path used by live stream failures while preserving the
|
||||
hydrated running plan and the existing bounded single-flight protection.
|
||||
*/
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage,
|
||||
});
|
||||
if (planningAutoRetryOwnerRef.current?.sessionId !== sessionId) {
|
||||
resetPlanningAutoRetryAttempts();
|
||||
}
|
||||
await startPlanningAutoRetry(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1692,6 +1713,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
connectToPlanningStream(sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (planningSessionLoadEpochRef.current !== loadEpoch || currentSessionIdRef.current !== sessionId) return;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setActivePlanPrompt("");
|
||||
setError(null);
|
||||
@@ -1702,7 +1724,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
});
|
||||
}
|
||||
},
|
||||
[connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t],
|
||||
[connectToPlanningStream, projectId, resetPlanningAutoRetryAttempts, resetPlanningAutoRetryBudget, startPlanningAutoRetry, t],
|
||||
);
|
||||
|
||||
// Resume the externally-requested session when the modal first opens.
|
||||
@@ -2278,7 +2300,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setView({ type: "loading" });
|
||||
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
||||
currentSessionIdRef.current = sessionId;
|
||||
liveGenerationSessionIdRef.current = sessionId;
|
||||
if (!streamConnectionRef.current?.isConnected()) {
|
||||
connectToPlanningStream(sessionId);
|
||||
}
|
||||
@@ -2375,7 +2396,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const history = conversationHistoryRef.current;
|
||||
|
||||
currentSessionIdRef.current = null;
|
||||
liveGenerationSessionIdRef.current = null;
|
||||
planningSessionLoadEpochRef.current += 1;
|
||||
streamConnectionEpochRef.current += 1;
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
@@ -2450,7 +2471,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}
|
||||
currentSessionIdRef.current = sessionId;
|
||||
liveGenerationSessionIdRef.current = sessionId;
|
||||
if (!streamConnectionRef.current?.isConnected()) {
|
||||
connectToPlanningStream(sessionId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
|
||||
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
|
||||
|
||||
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile"));
|
||||
const mockConnectPlanningStream = vi.hoisted(() => vi.fn());
|
||||
@@ -24,12 +24,12 @@ vi.mock("../../api", () => {
|
||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
|
||||
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
|
||||
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
||||
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: (...args: unknown[]) => mockRetryPlanningSession(...args), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
||||
};
|
||||
});
|
||||
|
||||
const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
|
||||
function renderSession(session: Record<string, unknown>) { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
||||
function renderSession() { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
||||
const summaryWithRefinements = {
|
||||
...mockSummary,
|
||||
description: "Build a **reviewed** recovery workflow with an operator [runbook](https://example.com/runbook).",
|
||||
@@ -39,7 +39,198 @@ const summaryWithRefinements = {
|
||||
};
|
||||
|
||||
describe("PlanningModeModal sequential flow", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
|
||||
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockRetryPlanningSession.mockResolvedValue({ success: true }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
|
||||
|
||||
/*
|
||||
FNXC:PlanningRetry 2026-07-21-10:00:
|
||||
Returning to Planning must recover every failed generation surface instead of rendering a
|
||||
terminal error: both a row already persisted as error and a resumed generating stream that
|
||||
subsequently reports its durable error dispatch the existing retry endpoint automatically.
|
||||
*/
|
||||
it("automatically retries a persisted error when returning to Planning", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "error",
|
||||
error: "The planning stream was interrupted",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
|
||||
renderSession();
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
expect(screen.queryByText("The planning stream was interrupted")).toBeNull();
|
||||
});
|
||||
|
||||
it("automatically retries a stream error after returning to a generating session", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "generating",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
|
||||
});
|
||||
renderSession();
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
|
||||
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "error",
|
||||
error: "The resumed stream failed",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
mockConnectPlanningStream.mock.calls[0]?.[2]?.onError?.("The resumed stream failed");
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
expect(screen.queryByText("The resumed stream failed")).toBeNull();
|
||||
});
|
||||
|
||||
it("retries all bounded attempts before surfacing a returned stream error", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "error",
|
||||
error: "The planning stream was interrupted",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
mockRetryPlanningSession.mockRejectedValue(new Error("Temporary retry outage"));
|
||||
|
||||
renderSession();
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3));
|
||||
expect(await screen.findByText("Temporary retry outage")).toBeInTheDocument();
|
||||
|
||||
mockConnectPlanningStream.mock.calls.at(-1)?.[2]?.onError?.("Late terminal stream error");
|
||||
await act(async () => Promise.resolve());
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3);
|
||||
expect(screen.getByText("Late terminal stream error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("coalesces overlapping stream errors into one retry request", async () => {
|
||||
let resolveRetry!: (value: { success: true }) => void;
|
||||
mockRetryPlanningSession.mockReturnValue(new Promise((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}));
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "error",
|
||||
error: "The planning stream was interrupted",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession();
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
mockConnectPlanningStream.mock.calls[0]?.[2]?.onError?.("Duplicate stream error");
|
||||
await act(async () => Promise.resolve());
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRetry({ success: true });
|
||||
});
|
||||
|
||||
it("ignores a stale errored load after a newer session is selected", async () => {
|
||||
const resolvers = new Map<string, (session: Record<string, unknown>) => void>();
|
||||
mockFetchAiSession.mockImplementation((sessionId: string) => new Promise((resolve) => {
|
||||
resolvers.set(sessionId, resolve);
|
||||
}));
|
||||
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
|
||||
const { rerender } = render(<PlanningModeModal {...props} resumeSessionId="session-a" />);
|
||||
await waitFor(() => expect(resolvers.has("session-a")).toBe(true));
|
||||
|
||||
rerender(<PlanningModeModal {...props} resumeSessionId="session-b" />);
|
||||
await waitFor(() => expect(resolvers.has("session-b")).toBe(true));
|
||||
resolvers.get("session-b")?.({
|
||||
...base,
|
||||
id: "session-b",
|
||||
status: "error",
|
||||
error: "Session B stream failed",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-b", "project-1"));
|
||||
|
||||
resolvers.get("session-a")?.({
|
||||
...base,
|
||||
id: "session-a",
|
||||
status: "error",
|
||||
error: "Session A stream failed",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
expect(mockRetryPlanningSession).not.toHaveBeenCalledWith("session-a", "project-1");
|
||||
});
|
||||
|
||||
it("keeps a stale retry completion from taking ownership from the newer session", async () => {
|
||||
let resolveSessionARetry!: (value: { success: true }) => void;
|
||||
mockRetryPlanningSession.mockImplementation((sessionId: string) => sessionId === "session-a"
|
||||
? new Promise((resolve) => {
|
||||
resolveSessionARetry = resolve;
|
||||
})
|
||||
: Promise.resolve({ success: true }));
|
||||
mockFetchAiSession.mockImplementation(async (sessionId: string) => ({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
error: `${sessionId} stream failed`,
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
}));
|
||||
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
|
||||
const { rerender } = render(<PlanningModeModal {...props} resumeSessionId="session-a" />);
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-a", "project-1"));
|
||||
|
||||
rerender(<PlanningModeModal {...props} resumeSessionId="session-b" />);
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-b", "project-1"));
|
||||
const connectionCountForB = mockConnectPlanningStream.mock.calls.filter(([sessionId]) => sessionId === "session-b").length;
|
||||
|
||||
resolveSessionARetry({ success: true });
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
expect(mockConnectPlanningStream.mock.calls.at(-1)?.[0]).toBe("session-b");
|
||||
expect(mockConnectPlanningStream.mock.calls.filter(([sessionId]) => sessionId === "session-b")).toHaveLength(connectionCountForB);
|
||||
expect(screen.queryByText("session-a stream failed")).toBeNull();
|
||||
});
|
||||
|
||||
it("automatically retries a resumed error discovered by the loading poll", async () => {
|
||||
const intervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "generating",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
|
||||
});
|
||||
renderSession();
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
|
||||
|
||||
const poll = intervalSpy.mock.calls.find(([, delay]) => delay === 8000)?.[0];
|
||||
expect(poll).toBeTypeOf("function");
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
status: "error",
|
||||
error: "Poll observed stream error",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
await act(async () => {
|
||||
await (poll as () => Promise<void>)();
|
||||
});
|
||||
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1");
|
||||
expect(screen.queryByText("Poll observed stream error")).toBeNull();
|
||||
intervalSpy.mockRestore();
|
||||
});
|
||||
it("persists a draft before generation and immediately shows initial-plan progress", async () => {
|
||||
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
|
||||
@@ -57,7 +248,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }),
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
const workspace = await screen.findByTestId("planning-workspace");
|
||||
expect(workspace).toHaveTextContent("Build authentication system");
|
||||
expect(workspace).toHaveTextContent("Which outcome matters most?");
|
||||
@@ -90,7 +281,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
expect(await screen.findByText("Old question?")).toBeInTheDocument();
|
||||
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
@@ -128,7 +319,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
}]),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
|
||||
const sessionsButton = await screen.findByRole("button", { name: "Sessions" });
|
||||
const historyButton = screen.getByRole("button", { name: "History" });
|
||||
@@ -198,7 +389,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
.mockRejectedValueOnce(Object.assign(new Error("Planning task creation is already in progress"), { status: 409 }))
|
||||
.mockResolvedValueOnce({ id: "FN-8442" });
|
||||
|
||||
renderSession({});
|
||||
renderSession();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||
|
||||
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442");
|
||||
@@ -266,7 +457,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
|
||||
const workspace = await screen.findByTestId("planning-workspace");
|
||||
// The viewport-mode hook is mocked without changing jsdom's CSS media viewport.
|
||||
@@ -296,7 +487,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
inputPayload: "{}",
|
||||
});
|
||||
mockRespondToPlanning.mockReturnValue(new Promise(() => undefined));
|
||||
renderSession({});
|
||||
renderSession();
|
||||
fireEvent.click(await screen.findByLabelText("Secure defaults"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
const workspace = screen.getByTestId("planning-workspace");
|
||||
@@ -327,7 +518,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
},
|
||||
summary: summaryWithRefinements,
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
expect(screen.getByTestId("planning-plan-pane")).toHaveTextContent("Build authentication system");
|
||||
expect(screen.getByTestId("planning-question-pane")).toHaveTextContent("What should the plan prioritize?");
|
||||
@@ -349,7 +540,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
it("restores the updating-plan progress state after refresh", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) });
|
||||
renderSession({});
|
||||
renderSession();
|
||||
expect(await screen.findByText("Generating plan…")).toBeInTheDocument();
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
|
||||
expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object));
|
||||
@@ -390,7 +581,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
conversationHistory: JSON.stringify([{ question: priorQuestion, response: { "q-prior": "Preserve drafts" } }]),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }),
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object)));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
@@ -443,7 +634,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
currentQuestion: { id: "q-refined", type: "text", question: "Which refined area comes first?" },
|
||||
summary: summaryWithRefinements,
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
@@ -469,7 +660,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
currentQuestion: { id: "q-replaced", type: "text", question: "What should the replacement prioritize?" },
|
||||
summary: summaryWithRefinements,
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Replace the current direction." } });
|
||||
@@ -500,14 +691,14 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession({});
|
||||
renderSession();
|
||||
expect(await screen.findByText("What should come next?")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Other (write your own)")).toHaveLength(1);
|
||||
});
|
||||
it("keeps detailed plan review and freeform refinement available on mobile", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
|
||||
renderSession({});
|
||||
renderSession();
|
||||
expect(await screen.findByText("What to change")).toBeInTheDocument();
|
||||
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: "Security boundaries" })).toBeNull();
|
||||
@@ -525,7 +716,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
it("restores a validated unlinked session to create-only retry", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
|
||||
renderSession({});
|
||||
renderSession();
|
||||
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Proceed with plan" })).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user