diff --git a/.changeset/fn-7946-planning-auto-retry.md b/.changeset/fn-7946-planning-auto-retry.md new file mode 100644 index 0000000000..da466f39fb --- /dev/null +++ b/.changeset/fn-7946-planning-auto-retry.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Planning Mode now auto-retries a stuck AI generation up to 3 times before showing an error. +category: feature +dev: Bounded client-side auto-retry in PlanningModeModal reusing the existing /planning/:id/retry endpoint; counter resets on successful progress and is single-flighted across SSE onError, reopen, and the stuck poll. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5063f70a01..977e7894e6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -454,6 +454,9 @@ Planning is a desktop/tablet left-sidebar main-content destination after **Comma When a Planning session is awaiting your input, look for the yellow needs-input dot on the Planning nav destination (desktop left sidebar; mobile More sheet item and More tab icon) rather than a banner — clicking Planning always opens the correct docked Planning view. + +When Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. + diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index b351d7c529..ac9c2eca8f 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -76,6 +76,8 @@ const PLANNING_SIDEBAR_MIN_WIDTH = 220; const PLANNING_SIDEBAR_MAX_WIDTH = 560; const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width"; +const MAX_PLANNING_AUTO_RETRIES = 3; + interface PlanningModeModalProps { isOpen: boolean; onClose: () => void; @@ -320,6 +322,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); const [isRetrying, setIsRetrying] = useState(false); + const [isAutoRetrying, setIsAutoRetrying] = useState(false); + const [autoRetryAttempt, setAutoRetryAttempt] = useState(0); const [isCreatingTask, setIsCreatingTask] = useState(false); const [isStartingBreakdown, setIsStartingBreakdown] = useState(false); const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false); @@ -351,6 +355,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const modalRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); + const viewRef = useRef({ type: "initial" }); + /* + 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. + */ + const planningAutoRetryAttemptRef = useRef(0); + const planningAutoRetryInFlightRef = useRef(false); + const startPlanningAutoRetryRef = useRef<(sessionId: string, errorMessage: string) => Promise>(async () => false); /* 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. @@ -370,6 +382,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // yanks the user back into the previous session's question view. const dismissedResumeRef = useRef(null); const [lockSessionId, setLockSessionId] = useState(resumeSessionId ?? null); + + useEffect(() => { + viewRef.current = view; + }, [view]); + + const resetPlanningAutoRetryBudget = useCallback(() => { + planningAutoRetryAttemptRef.current = 0; + planningAutoRetryInFlightRef.current = false; + setAutoRetryAttempt(0); + setIsAutoRetrying(false); + }, []); const sessionTabId = useMemo(() => getSessionTabId(), []); const { isLockedByOther, @@ -589,6 +612,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (cancelled || !session) return; if (currentSessionIdRef.current !== sessionId) return; if (session.status === "awaiting_input" && session.currentQuestion) { + resetPlanningAutoRetryBudget(); const question = JSON.parse(session.currentQuestion) as PlanningQuestion; setView({ type: "question", @@ -596,6 +620,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); setStreamingOutput(""); } else if (session.status === "complete" && session.result) { + resetPlanningAutoRetryBudget(); const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary); setView({ type: "summary", @@ -604,6 +629,46 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); setEditedSummary(summary); setStreamingOutput(""); + } else if (session.status === "error") { + const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); + const handled = await startPlanningAutoRetryRef.current(sessionId, errorMessage); + if (handled) return; + if (cancelled || currentSessionIdRef.current !== sessionId) return; + /* + FNXC:PlanningRetry 2026-07-13-00:05: + Mirror the SSE onError terminal-error transition here: when this poll is the one that + discovers a terminal session error (missed SSE event) and the auto-retry budget is + already exhausted, startPlanningAutoRetryRef resolves false and previously nothing + transitioned the view out of "loading" — the modal was stuck spinning on + "Generating next question..." forever, re-polling every 8s with no visible progress. + Build the permanent error view exactly like connectToPlanningStream's onError does. + */ + setIsRetrying(false); + setIsAutoRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; + setError(null); + setView((prev) => { + if (prev.type === "question" || prev.type === "summary" || prev.type === "error") { + return { type: "error", session: prev.session, errorMessage }; + } + return { + type: "error", + session: { sessionId, currentQuestion: null, summary: null }, + errorMessage, + }; + }); + setStreamingOutput(""); + broadcastUpdate({ + sessionId, + status: "error", + needsInput: false, + owningTabId: sessionTabId, + type: "planning", + title: initialPlan.trim() || undefined, + projectId: projectId ?? null, + }); + broadcastCompleted({ sessionId, status: "error" }); } } catch { // best-effort; keep polling @@ -615,7 +680,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat cancelled = true; clearInterval(interval); }; - }, [view.type]); + }, [broadcastCompleted, broadcastUpdate, initialPlan, lockSessionId, projectId, resetPlanningAutoRetryBudget, sessionTabId, t, view.type]); const resetDetailState = useCallback(() => { setInitialPlan(""); @@ -630,6 +695,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); + resetPlanningAutoRetryBudget(); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; setPlanningModelProvider(undefined); @@ -639,7 +705,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setCustomQuestionCount(""); currentSessionIdRef.current = null; setLockSessionId(null); - }, []); + }, [resetPlanningAutoRetryBudget]); const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId); @@ -744,6 +810,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const normalizedQuestion = normalizeQuestionOptions(question); setIsReconnecting(false); setIsRetrying(false); + resetPlanningAutoRetryBudget(); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; clearPlanningDescription(projectId); @@ -785,6 +852,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const normalizedSummary = normalizePlanningSummary(summary); setIsReconnecting(false); setIsRetrying(false); + resetPlanningAutoRetryBudget(); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; clearPlanningDescription(projectId); @@ -841,7 +909,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } setIsReconnecting(false); + /* + FNXC:PlanningRetry 2026-07-13-00:00: + A terminal/persisted Planning Mode generation error is treated as a stuck-class turn. Try the existing /planning/:id/retry path up to MAX_PLANNING_AUTO_RETRIES before surfacing the permanent Retry/Dismiss error panel; overlapping SSE and poll signals share the same single-flight guard. + */ + if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) { + return; + } setIsRetrying(false); + setIsAutoRetrying(false); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; setError(null); @@ -873,6 +949,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat onComplete: () => { setIsReconnecting(false); setIsRetrying(false); + resetPlanningAutoRetryBudget(); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; currentSessionIdRef.current = null; @@ -885,9 +962,141 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current = connection; }, - [broadcastCompleted, broadcastUpdate, initialPlan, projectId, sessionTabId], + [broadcastCompleted, broadcastUpdate, initialPlan, projectId, resetPlanningAutoRetryBudget, sessionTabId], ); + const startPlanningRetry = useCallback( + async (retryTarget: { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null }, options: { auto: boolean }) => { + setError(null); + setIsRetrying(!options.auto); + setIsAutoRetrying(options.auto); + setStreamingOutput(""); + setView({ type: "loading" }); + + currentSessionIdRef.current = retryTarget.sessionId; + setLockSessionId(retryTarget.sessionId); + connectToPlanningStream(retryTarget.sessionId); + + try { + await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId); + } catch (err) { + let retryError: unknown = err; + const retryErrorMessage = getErrorMessage(err) || ""; + + if (retryErrorMessage.includes("not in an error state")) { + try { + const session = await fetchAiSession(retryTarget.sessionId); + if (!session) { + throw new Error("Failed to refresh planning session."); + } + + currentSessionIdRef.current = session.id; + setLockSessionId(session.id); + + if (session.status === "generating") { + setStreamingOutput(session.thinkingOutput ?? ""); + setView({ type: "loading" }); + } else if (session.status === "awaiting_input") { + if (!session.currentQuestion) { + throw new Error("Planning session is awaiting input but has no current question."); + } + resetPlanningAutoRetryBudget(); + const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion) as PlanningQuestion); + clearPlanningDescription(projectId); + setView({ + type: "question", + session: { sessionId: session.id, currentQuestion: question, summary: null }, + }); + if (session.thinkingOutput) { + const trimmed = session.thinkingOutput.trim(); + if (trimmed) { + setConversationHistory((prev) => { + const lastEntry = prev[prev.length - 1]; + if (lastEntry?.thinkingOutput === trimmed) return prev; + return [...prev, { thinkingOutput: trimmed }]; + }); + } + } + if (!streamConnectionRef.current?.isConnected()) { + connectToPlanningStream(session.id); + } + } else if (session.status === "complete") { + if (!session.result) { + throw new Error("Planning session is complete but has no result."); + } + resetPlanningAutoRetryBudget(); + const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary); + clearPlanningDescription(projectId); + setView({ + type: "summary", + session: { sessionId: session.id, currentQuestion: null, summary }, + summary, + }); + setEditedSummary(summary); + } else if (session.status === "error") { + setView({ + type: "error", + session: { sessionId: session.id, currentQuestion: null, summary: null }, + errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."), + }); + setIsAutoRetrying(false); + } + + setIsReconnecting(false); + return; + } catch (sessionRefreshError) { + retryError = sessionRefreshError; + } + } + + streamConnectionRef.current?.close(); + streamConnectionRef.current = null; + setView({ + type: "error", + session: retryTarget, + errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."), + }); + setIsReconnecting(false); + setIsAutoRetrying(false); + } finally { + if (!options.auto) { + setIsRetrying(false); + } + planningAutoRetryInFlightRef.current = false; + } + }, + [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, sessionTabId, t], + ); + + const startPlanningAutoRetry = useCallback( + async (sessionId: string, _errorMessage: string) => { + if (viewRef.current.type === "error") { + return false; + } + if (planningAutoRetryInFlightRef.current) { + return true; + } + if (planningAutoRetryAttemptRef.current >= MAX_PLANNING_AUTO_RETRIES) { + setIsAutoRetrying(false); + return false; + } + + const attempt = planningAutoRetryAttemptRef.current + 1; + planningAutoRetryAttemptRef.current = attempt; + planningAutoRetryInFlightRef.current = true; + setAutoRetryAttempt(attempt); + setIsAutoRetrying(true); + await startPlanningRetry( + { sessionId, currentQuestion: null, summary: null }, + { auto: true }, + ); + return true; + }, + [startPlanningRetry], + ); + + startPlanningAutoRetryRef.current = startPlanningAutoRetry; + const handleStartPlanning = useCallback(async (planOverride?: string) => { const plan = planOverride ?? initialPlan; if (!plan.trim()) return; @@ -897,6 +1106,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setConversationHistory([]); setResponseHistory([]); setIsReconnecting(false); + resetPlanningAutoRetryBudget(); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; setView({ type: "loading" }); @@ -948,6 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat planningModelProvider, planningThinkingLevel, projectId, + resetPlanningAutoRetryBudget, ]); /* @@ -1033,10 +1244,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ); if (session.status === "error") { + const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); + if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) { + return; + } setView({ type: "error", session: { sessionId, currentQuestion: null, summary: null }, - errorMessage: session.error || t("planning.sessionFailed2", "Session failed"), + errorMessage, }); return; } @@ -1083,6 +1298,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat : null; setView({ type: "initial" }); } else if (session.status === "awaiting_input" && session.currentQuestion) { + resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion)); setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } }); @@ -1102,6 +1318,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } connectToPlanningStream(sessionId); } else if (session.status === "complete" && session.result) { + resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); const summary = normalizePlanningSummary(JSON.parse(session.result)); setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary }); @@ -1122,7 +1339,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); } }, - [connectToPlanningStream, projectId], + [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget], ); // Resume the externally-requested session when the modal first opens. @@ -1682,6 +1899,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }, ]; }); + resetPlanningAutoRetryBudget(); setView({ type: "loading" }); setStreamingOutput(""); // Clear old thinking output when entering loading state @@ -1694,7 +1912,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "question", session }); } }, - [projectId, sessionTabId, view] + [projectId, resetPlanningAutoRetryBudget, sessionTabId, view] ); const handleRefineFurther = useCallback(async () => { @@ -1711,6 +1929,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsRefiningSummary(true); setError(null); setIsRetrying(false); + resetPlanningAutoRetryBudget(); setStreamingOutput(""); setView({ type: "loading" }); @@ -1730,7 +1949,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setError(message); setView({ type: "summary", session, summary: editedSummary ?? summary }); } - }, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]); + }, [connectToPlanningStream, editedSummary, projectId, resetPlanningAutoRetryBudget, sessionTabId, view]); const handleStopGeneration = useCallback(async () => { const sessionId = currentSessionIdRef.current; @@ -1748,6 +1967,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); + setIsAutoRetrying(false); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; setView({ @@ -1763,97 +1983,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat return; } - const retryTarget = view.session; - setError(null); - setIsRetrying(true); - setStreamingOutput(""); - setView({ type: "loading" }); - - connectToPlanningStream(retryTarget.sessionId); - - try { - currentSessionIdRef.current = retryTarget.sessionId; - setLockSessionId(retryTarget.sessionId); - await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId); - } catch (err) { - let retryError: unknown = err; - const retryErrorMessage = getErrorMessage(err) || ""; - - if (retryErrorMessage.includes("not in an error state")) { - try { - const session = await fetchAiSession(retryTarget.sessionId); - if (!session) { - throw new Error("Failed to refresh planning session."); - } - - currentSessionIdRef.current = session.id; - setLockSessionId(session.id); - - if (session.status === "generating") { - setStreamingOutput(session.thinkingOutput ?? ""); - setView({ type: "loading" }); - } else if (session.status === "awaiting_input") { - if (!session.currentQuestion) { - throw new Error("Planning session is awaiting input but has no current question."); - } - const question = JSON.parse(session.currentQuestion) as PlanningQuestion; - clearPlanningDescription(projectId); - setView({ - type: "question", - session: { sessionId: session.id, currentQuestion: question, summary: null }, - }); - if (session.thinkingOutput) { - const trimmed = session.thinkingOutput.trim(); - if (trimmed) { - setConversationHistory((prev) => { - const lastEntry = prev[prev.length - 1]; - if (lastEntry?.thinkingOutput === trimmed) return prev; - return [...prev, { thinkingOutput: trimmed }]; - }); - } - } - if (!streamConnectionRef.current?.isConnected()) { - connectToPlanningStream(session.id); - } - } else if (session.status === "complete") { - if (!session.result) { - throw new Error("Planning session is complete but has no result."); - } - const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary); - clearPlanningDescription(projectId); - setView({ - type: "summary", - session: { sessionId: session.id, currentQuestion: null, summary }, - summary, - }); - setEditedSummary(summary); - } else if (session.status === "error") { - setView({ - type: "error", - session: { sessionId: session.id, currentQuestion: null, summary: null }, - errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."), - }); - } - - setIsReconnecting(false); - return; - } catch (sessionRefreshError) { - retryError = sessionRefreshError; - } - } - - streamConnectionRef.current?.close(); - streamConnectionRef.current = null; - setView({ - type: "error", - session: retryTarget, - errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."), - }); - setIsReconnecting(false); - } finally { - setIsRetrying(false); - } - }, [connectToPlanningStream, projectId, sessionTabId, view]); + resetPlanningAutoRetryBudget(); + await startPlanningRetry(view.session, { auto: false }); + }, [resetPlanningAutoRetryBudget, startPlanningRetry, view]); const handleCreateTask = useCallback(async () => { if (view.type !== "summary") return; @@ -2370,7 +2502,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {view.type === "loading" && (
-

{streamingOutput ? t("planning.aiThinking", "AI is thinking...") : t("planning.generatingQuestion", "Generating next question...")}

+

+ {isAutoRetrying && autoRetryAttempt > 0 + ? t("planning.autoRetrying", "Retrying… (attempt {{attempt}} of {{max}})", { + attempt: autoRetryAttempt, + max: MAX_PLANNING_AUTO_RETRIES, + }) + : streamingOutput + ? t("planning.aiThinking", "AI is thinking...") + : t("planning.generatingQuestion", "Generating next question...")} +

{generationStartTime && (
{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}
)} 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 2901b164cb..4017a14b1b 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -942,18 +942,32 @@ describe("PlanningModeModal", () => { expect(streamHandlers).toBeDefined(); }); - it("shows error message when planning fails", async () => { - // Override the default mock to simulate an error - mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { - setTimeout(() => { - handlers.onError?.("Rate limit exceeded"); - }, 10); - + it("auto-retries a persisted stream error three times before showing the permanent error", async () => { + const streamHandlers: any[] = []; + mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers.push(handlers); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); + mockFetchAiSession.mockResolvedValue({ + id: "session-123", + type: "planning", + status: "error", + title: "Build auth system", + inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Rate limit exceeded", + projectId: null, + lockedByTab: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lockedAt: null, + }); render( { onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} - /> + />, ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); - fireEvent.click(screen.getByText("Start Planning")); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + + await act(async () => { + streamHandlers[0].onError?.("Rate limit exceeded"); + }); + await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1)); + expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); + + await act(async () => { + streamHandlers[1].onError?.("Rate limit exceeded"); + }); + await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(2)); + expect(screen.getByText("Retrying… (attempt 2 of 3)")).toBeDefined(); + + await act(async () => { + streamHandlers[2].onError?.("Rate limit exceeded"); + }); + await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3)); + expect(screen.getByText("Retrying… (attempt 3 of 3)")).toBeDefined(); + + await act(async () => { + streamHandlers[3].onError?.("Rate limit exceeded"); + }); await waitFor(() => { expect(screen.getByText("Rate limit exceeded")).toBeDefined(); }); + expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3); expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); }); - it("retries from error state and reconnects stream", async () => { - let streamAttempt = 0; + it("manual retry still starts a fresh retry after the auto-retry budget is exhausted", async () => { + const streamHandlers: any[] = []; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { - streamAttempt += 1; - if (streamAttempt === 1) { - setTimeout(() => handlers.onError?.("Temporary failure"), 10); - } else { - setTimeout(() => handlers.onQuestion?.(mockQuestion), 10); - } - + streamHandlers.push(handlers); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); + mockFetchAiSession.mockResolvedValue({ + id: "session-123", + type: "planning", + status: "error", + title: "Build auth system", + inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Temporary failure", + projectId: null, + lockedByTab: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lockedAt: null, + }); render( { }); fireEvent.click(screen.getByText("Start Planning")); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + for (let index = 0; index < 4; index += 1) { + await act(async () => { + streamHandlers[index].onError?.("Temporary failure"); + }); + } + await waitFor(() => { expect(screen.getByText("Temporary failure")).toBeDefined(); }); + expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3); + mockFetchAiSession.mockResolvedValue({ + id: "session-123", + type: "planning", + status: "generating", + title: "Build auth system", + inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: null, + projectId: null, + lockedByTab: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lockedAt: null, + }); fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => { - expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); + expect(mockRetryPlanningSession).toHaveBeenCalledTimes(4); + }); + await act(async () => { + streamHandlers[4].onQuestion?.(mockQuestion); }); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); - expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); + }); + + it("resets the auto-retry budget after successful question progress", async () => { + const streamHandlers: any[] = []; + mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers.push(handlers); + return { + close: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + }; + }); + mockFetchAiSession.mockResolvedValue({ + id: "session-123", + type: "planning", + status: "error", + title: "Build auth system", + inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Temporary failure", + projectId: null, + lockedByTab: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lockedAt: null, + }); + + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { + target: { value: "Build auth system" }, + }); + fireEvent.click(screen.getByText("Start Planning")); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + + await act(async () => { + streamHandlers[0].onError?.("Temporary failure"); + }); + await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined()); + + await act(async () => { + streamHandlers[1].onQuestion?.(mockQuestion); + }); + await waitFor(() => expect(screen.getByText("What is the scope?")).toBeDefined()); + + await act(async () => { + streamHandlers[1].onError?.("Temporary failure"); + }); + await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(2)); + expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); + expect(screen.queryByText("Temporary failure")).toBeNull(); + }); + + it("single-flights overlapping SSE error and stuck-poll retry signals", async () => { + const streamHandlers: any[] = []; + let pollTick: (() => void | Promise) | undefined; + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler, timeout?: number) => { + if (timeout === 8000) { + pollTick = callback as () => void | Promise; + } + return 1 as unknown as ReturnType; + }); + let resolveRetry!: (value: { success: boolean; sessionId: string }) => void; + const retryPromise = new Promise<{ success: boolean; sessionId: string }>((resolve) => { + resolveRetry = resolve; + }); + mockRetryPlanningSession.mockReturnValue(retryPromise); + mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers.push(handlers); + return { + close: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + }; + }); + mockFetchAiSession.mockResolvedValue({ + id: "session-123", + type: "planning", + status: "error", + title: "Build auth system", + inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Temporary failure", + projectId: null, + lockedByTab: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lockedAt: null, + }); + + try { + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { + target: { value: "Build auth system" }, + }); + fireEvent.click(screen.getByText("Start Planning")); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + await waitFor(() => expect(pollTick).toBeDefined()); + + await act(async () => { + void streamHandlers[0].onError?.("Temporary failure"); + await Promise.resolve(); + await pollTick?.(); + }); + + expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1); + expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); + + await act(async () => { + resolveRetry({ success: true, sessionId: "session-123" }); + }); + } finally { + setIntervalSpy.mockRestore(); + } + }); + + it("surfaces the permanent error view once the stuck-poll fallback exhausts the auto-retry budget without any SSE onError signal", async () => { + // FN-7946 regression: if the SSE connection never invokes onError (e.g. a + // dropped event) and the 8s watchdog poll is the only signal that discovers + // a terminal session error, the poll path must still surface the permanent + // error view once MAX_PLANNING_AUTO_RETRIES is exhausted — not leave the + // modal stuck on the loading spinner forever. + const streamHandlers: any[] = []; + const pollTicks: Array<() => void | Promise> = []; + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler, timeout?: number) => { + if (timeout === 8000) { + pollTicks.push(callback as () => void | Promise); + } + return 1 as unknown as ReturnType; + }); + mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers.push(handlers); + return { + close: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + }; + }); + mockFetchAiSession.mockResolvedValue({ + id: "session-123", + type: "planning", + status: "error", + title: "Build auth system", + inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Watchdog aborted a stalled turn", + projectId: null, + lockedByTab: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lockedAt: null, + }); + + try { + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { + target: { value: "Build auth system" }, + }); + fireEvent.click(screen.getByText("Start Planning")); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + + // Drive every retry attempt purely through the watchdog poll — the SSE + // handlers never call onError, simulating a missed/dropped SSE event. + // The interval is registered once while the view stays "loading" across + // retries (lockSessionId/session id do not change), so the same captured + // tick callback is re-invoked on each simulated 8s beat, exactly as the + // real setInterval would re-invoke it. + await waitFor(() => expect(pollTicks.length).toBeGreaterThan(0)); + for (let index = 0; index < 4; index += 1) { + await act(async () => { + await pollTicks[0]?.(); + }); + } + + await waitFor(() => { + expect(screen.getByText("Watchdog aborted a stalled turn")).toBeDefined(); + }); + expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); + expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3); + } finally { + setIntervalSpy.mockRestore(); + } }); it("auto-recovers from a stream error when server session is still generating", async () => { @@ -1966,7 +2255,7 @@ describe("PlanningModeModal", () => { expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined(); }); - it("shows retry panel when resuming an errored session and retries the same session", async () => { + it("auto-retries when resuming an errored session", async () => { mockFetchAiSession.mockResolvedValueOnce({ id: "session-error-1", type: "planning", @@ -1995,19 +2284,15 @@ describe("PlanningModeModal", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("alert")).toHaveTextContent("Session interrupted"); - }); - expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - await waitFor(() => { expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined, expect.any(String)); }); + expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); }); - it("shows retry panel when selecting an errored session from the sidebar", async () => { + it("auto-retries when selecting an errored session from the sidebar", async () => { mockFetchAiSessions.mockResolvedValueOnce([ { id: "session-sidebar-error", @@ -2055,15 +2340,11 @@ describe("PlanningModeModal", () => { await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error"); - expect(screen.getByRole("alert")).toHaveTextContent("Sidebar session interrupted"); - }); - expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - - await waitFor(() => { expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined, expect.any(String)); }); + expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); }); it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {