diff --git a/.changeset/retry-returned-planning-streams.md b/.changeset/retry-returned-planning-streams.md new file mode 100644 index 0000000000..df35dc3b0a --- /dev/null +++ b/.changeset/retry-returned-planning-streams.md @@ -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. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 916dddc3bb..3628a0d101 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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(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>(async () => false); + const planningAutoRetryOwnerRef = useRef<{ sessionId: string; token: symbol } | null>(null); + const startPlanningAutoRetryRef = useRef<(sessionId: string) => Promise>(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); } 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 97cd79fde4..cd0bd70226 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -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) { return render(); } +function renderSession() { return render(); } 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) => 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(); + await waitFor(() => expect(resolvers.has("session-a")).toBe(true)); + + rerender(); + 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(); + await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-a", "project-1")); + + rerender(); + 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)(); + }); + + 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(); 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(); });