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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8332-planning-reload-resume.md
Normal file
7
.changeset/fn-8332-planning-reload-resume.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:PlanningRetry 2026-07-13-00:00: If Planning AI generation stalls and the server persists a terminal generation error, Planning Mode should auto-retry the same session up to three times before showing the permanent Retry/Dismiss error panel; any question or summary progress resets that budget. -->
|
||||
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.
|
||||
<!-- FNXC:PlanningRetry 2026-07-15-00:00: FN-8332 confines automatic Planning Mode retry to failures observed by an active in-session SSE/poll turn. Browser reload or session resume must restore the persisted progress/error verbatim and leave retry as an explicit user choice. -->
|
||||
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.
|
||||
|
||||
<!-- FNXC:Planning 2026-07-15-00:00: FN-8003 preserves the user’s original planning idea across error and mid-interview recovery surfaces. -->
|
||||
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.
|
||||
|
||||
@@ -369,6 +369,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const viewRef = useRef<ViewState>({ 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<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.
|
||||
*/
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
<PlanningModeModal
|
||||
@@ -2392,23 +2383,148 @@ describe("PlanningModeModal", () => {
|
||||
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(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-resumed-generating-stream"
|
||||
/>,
|
||||
);
|
||||
|
||||
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<void>) | undefined;
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler, timeout?: number) => {
|
||||
if (timeout === 8000) {
|
||||
pollTick = callback as () => void | Promise<void>;
|
||||
}
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
});
|
||||
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(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-resumed-generating-poll"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal
|
||||
@@ -2447,19 +2562,13 @@ describe("PlanningModeModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user