From e0d2fd60853c5a47b24ba2ac5192702ec5509405 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 13:32:33 -0700 Subject: [PATCH] FN-8332: preserve planning session progress after reload Restore persisted Planning Mode sessions without starting a new generation. - Limit automatic retries to generations started by the current mounted UI - Render saved questions, summaries, thinking, and errors with manual retry after reload or resume - Cover desktop, mobile, stream, and polling recovery paths and document the behavior - Add a patch changeset for the published package Files changed: .changeset/fn-8332-planning-reload-resume.md | 7 + docs/dashboard-guide.md | 4 +- .../dashboard/app/components/PlanningModeModal.tsx | 36 ++++- .../PlanningModeModal.planning-flow.test.tsx | 175 +++++++++++++++++---- 4 files changed, 180 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-8332 Fusion-Task-Lineage: 6b128dc3-a091-4e81-bc6a-9c541d8e4cf5 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8332-planning-reload-resume.md | 7 + docs/dashboard-guide.md | 4 +- .../app/components/PlanningModeModal.tsx | 36 +++- .../PlanningModeModal.planning-flow.test.tsx | 177 ++++++++++++++---- 4 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 .changeset/fn-8332-planning-reload-resume.md diff --git a/.changeset/fn-8332-planning-reload-resume.md b/.changeset/fn-8332-planning-reload-resume.md new file mode 100644 index 0000000000..99b6159f23 --- /dev/null +++ b/.changeset/fn-8332-planning-reload-resume.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Resume saved Planning Mode progress after reload without automatically re-running generation. +category: fix +dev: Persisted planning errors now restore the manual Retry/Dismiss panel; automatic retry remains live-turn only. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ba978ef2b5..c44c8c8b85 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -511,8 +511,8 @@ Planning is a desktop/tablet left-sidebar main-content destination after **Comma When a Planning session needs your input or needs attention, open the docked Planning view from the **Planning** navigation item. Its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls. Non-planning in-progress, needs-input, and error sessions appear in the session notification banner, where available Resume actions reconnect to their matching surface. - -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. + +When an active 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. Reopening or reloading a saved Planning session restores its saved question, summary, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again. Use **Copy prompt** in the error panel or an active interview question to copy the original “What do you want to build?” text, then paste it into **New session** to restart cleanly. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index ecbdf2cd91..9493734613 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -369,6 +369,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const currentSessionIdRef = useRef(null); const viewRef = useRef({ type: "initial" }); /* + 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. */ @@ -665,7 +673,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setStreamingOutput(""); } else if (session.status === "error") { const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); - const handled = await startPlanningAutoRetryRef.current(sessionId, errorMessage); + const handled = liveGenerationSessionIdRef.current === sessionId + && await startPlanningAutoRetryRef.current(sessionId, errorMessage); if (handled) return; if (cancelled || currentSessionIdRef.current !== sessionId) return; /* @@ -907,10 +916,16 @@ 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. + 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. */ - if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) { + if ( + liveGenerationSessionIdRef.current === sessionId + && await startPlanningAutoRetryRef.current(sessionId, errorMessage) + ) { return; } setIsRetrying(false); @@ -959,6 +974,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "loading" }); currentSessionIdRef.current = retryTarget.sessionId; + liveGenerationSessionIdRef.current = retryTarget.sessionId; connectToPlanningStream(retryTarget.sessionId); try { @@ -1142,6 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ); draftSessionIdRef.current = null; currentSessionIdRef.current = sessionId; + liveGenerationSessionIdRef.current = sessionId; setSelectedSessionId(sessionId); connectToPlanningStream(sessionId); @@ -1225,6 +1242,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat async (sessionId: string) => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; + // Loading a database row never makes its in-flight turn local to this mount. + liveGenerationSessionIdRef.current = null; setError(null); setStreamingOutput(""); @@ -1272,9 +1291,10 @@ 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; - } + /* + 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. + */ setView({ type: "error", session: { sessionId, currentQuestion: null, summary: null }, @@ -1872,6 +1892,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat resetPlanningAutoRetryBudget(); setView({ type: "loading" }); setStreamingOutput(""); // Clear old thinking output when entering loading state + liveGenerationSessionIdRef.current = sessionId; try { // Submit response - AI will broadcast events via the already-connected stream @@ -1901,6 +1922,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat resetPlanningAutoRetryBudget(); setStreamingOutput(""); setView({ type: "loading" }); + liveGenerationSessionIdRef.current = sessionId; 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 e79ce15dc5..8ebfde92ab 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -2358,23 +2358,15 @@ describe("PlanningModeModal", () => { expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined(); }); - it("auto-retries when resuming an errored session", async () => { - /* - * FNXC:PlanningRetry 2026-07-15-00:00: - * FN-8025 requires the stream to remain in the retry loading window while this test observes the transient status. - * Do not use the suite default here: its delayed question event clears the auto-retry state before the assertion can run. - */ - mockConnectPlanningStream.mockImplementation(() => ({ - close: vi.fn(), - isConnected: vi.fn().mockReturnValue(true), - })); + it.each(["desktop", "mobile"] as const)("FN-8332 restores an errored resumed session without auto-retry on %s", async (viewportMode) => { + mockViewport(viewportMode); mockFetchAiSession.mockResolvedValueOnce({ - id: "session-error-1", + id: `session-error-${viewportMode}`, type: "planning", status: "error", title: "Errored planning", inputPayload: JSON.stringify({ initialPlan: "Recover planning" }), - conversationHistory: "[]", + conversationHistory: JSON.stringify([{ thinkingOutput: "Persisted analysis" }]), currentQuestion: null, result: null, thinkingOutput: "", @@ -2383,7 +2375,6 @@ describe("PlanningModeModal", () => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-error-1" }); render( { onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} - resumeSessionId="session-error-1" + resumeSessionId={`session-error-${viewportMode}`} />, ); - await waitFor(() => { - expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined); - }); - await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined()); - expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); + expect(await screen.findByRole("alert")).toHaveTextContent("Session interrupted"); + fireEvent.click(screen.getByRole("button", { name: "Show AI reasoning" })); + expect(screen.getByText("Persisted analysis")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument(); + expect(mockRetryPlanningSession).not.toHaveBeenCalled(); + expect(mockStartPlanningStreaming).not.toHaveBeenCalled(); }); - it("auto-retries when selecting an errored session from the sidebar", async () => { - mockConnectPlanningStream.mockImplementation(() => ({ + it("FN-8332 keeps a resumed generating stream error manual", async () => { + let streamHandlers: any; + mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers = handlers; + return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) }; + }); + mockFetchAiSession + .mockResolvedValueOnce({ + id: "session-resumed-generating-stream", + type: "planning", + status: "generating", + title: "Resumed generation", + inputPayload: JSON.stringify({ initialPlan: "Restore a live server turn" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "Persisted thinking", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }) + .mockResolvedValueOnce({ + id: "session-resumed-generating-stream", + type: "planning", + status: "error", + title: "Resumed generation", + inputPayload: JSON.stringify({ initialPlan: "Restore a live server turn" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "Persisted thinking", + error: "Persisted server failure", + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }); + + render( + , + ); + + await waitFor(() => expect(streamHandlers).toBeDefined()); + await act(async () => { + streamHandlers.onError?.("Stream disconnected"); + }); + + expect(await screen.findByRole("alert")).toHaveTextContent("Stream disconnected"); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(mockRetryPlanningSession).not.toHaveBeenCalled(); + }); + + it("FN-8332 keeps a resumed generating poll error manual", async () => { + 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; + }); + mockConnectPlanningStream.mockImplementationOnce(() => ({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), })); + mockFetchAiSession + .mockResolvedValueOnce({ + id: "session-resumed-generating-poll", + type: "planning", + status: "generating", + title: "Resumed polling generation", + inputPayload: JSON.stringify({ initialPlan: "Restore polling turn" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }) + .mockResolvedValueOnce({ + id: "session-resumed-generating-poll", + type: "planning", + status: "error", + title: "Resumed polling generation", + inputPayload: JSON.stringify({ initialPlan: "Restore polling turn" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Persisted polling failure", + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }); + + try { + render( + , + ); + + await waitFor(() => expect(pollTick).toBeDefined()); + await act(async () => { + await pollTick?.(); + }); + + expect(await screen.findByRole("alert")).toHaveTextContent("Persisted polling failure"); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(mockRetryPlanningSession).not.toHaveBeenCalled(); + } finally { + setIntervalSpy.mockRestore(); + } + }); + + it("FN-8332 restores an errored sidebar selection without auto-retry", async () => { mockFetchAiSessions.mockResolvedValueOnce([ { id: "session-sidebar-error", @@ -2435,7 +2551,6 @@ describe("PlanningModeModal", () => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", }); - mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-sidebar-error" }); render( { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /Sidebar errored session/i })).toBeDefined(); - }); - + await screen.findByRole("button", { name: /Sidebar errored session/i }); fireEvent.click(screen.getByRole("button", { name: /Sidebar errored session/i })); - await waitFor(() => { - expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error"); - expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined); - }); - await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined()); - expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); + expect(await screen.findByRole("alert")).toHaveTextContent("Sidebar session interrupted"); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(mockRetryPlanningSession).not.toHaveBeenCalled(); + expect(mockStartPlanningStreaming).not.toHaveBeenCalled(); }); it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {