FN-5908: recover planning sessions after load failures
Keep failed planning session resumes recoverable instead of dead-ending in the empty planner. - route errored persisted planning sessions into the retryable error view during session restore - preserve the session id and surface load/parsing errors through Retry/Dismiss recovery instead of a generic load failure reset - add planning modal coverage for errored sidebar sessions, malformed persisted results, reopen resync, retry reuse, and missing-session fallback behavior Files changed: .changeset/fair-lizards-jump.md | 5 + .../dashboard/app/components/PlanningModeModal.tsx | 27 +- .../PlanningModeModal.planning-flow.test.tsx | 284 ++++++++++++++++++++- 3 files changed, 305 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-5908 Fusion-Task-Lineage: 6904c223-562d-45c5-8965-71b5b90dcbe9
This commit is contained in:
5
.changeset/fair-lizards-jump.md
Normal file
5
.changeset/fair-lizards-jump.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Recover failed Planning Mode session loads into the existing retryable error view instead of dropping back to the empty planner. Failed or malformed persisted planning sessions now keep their session id so Retry/Dismiss recovery remains available, while deleted sessions still quietly fall back to a new session.
|
||||
@@ -806,6 +806,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
),
|
||||
);
|
||||
|
||||
if (session.status === "error") {
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage: session.error || "Session failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === "draft") {
|
||||
// Draft hasn't been started yet — restore the user's saved text +
|
||||
// model selection into the editor, reattach the draft id so a
|
||||
@@ -869,16 +878,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setView({ type: "loading" });
|
||||
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
|
||||
connectToPlanningStream(sessionId);
|
||||
} else if (session.status === "error") {
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage: session.error || "Session failed",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load session");
|
||||
setView({ type: "initial" });
|
||||
} catch (err) {
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
setError(null);
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage: getErrorMessage(err) || "Failed to load session",
|
||||
});
|
||||
}
|
||||
},
|
||||
[connectToPlanningStream, projectId],
|
||||
|
||||
@@ -1046,7 +1046,7 @@ describe("PlanningModeModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows retry panel when resuming an errored session", async () => {
|
||||
it("shows retry panel when resuming an errored session and retries the same session", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-error-1",
|
||||
type: "planning",
|
||||
@@ -1062,6 +1062,7 @@ 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
|
||||
@@ -1075,9 +1076,288 @@ describe("PlanningModeModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Session interrupted")).toBeDefined();
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry panel when selecting an errored session from the sidebar", async () => {
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
{
|
||||
id: "session-sidebar-error",
|
||||
type: "planning",
|
||||
status: "error",
|
||||
title: "Sidebar errored session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-sidebar-error",
|
||||
type: "planning",
|
||||
status: "error",
|
||||
title: "Sidebar errored session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover sidebar session" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: "Sidebar session interrupted",
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-sidebar-error" });
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Sidebar errored session/i })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Sidebar errored session/i }));
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
{
|
||||
id: "session-malformed-result",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Malformed result session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-malformed-result",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Malformed result session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover malformed result" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: "{",
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Malformed result session/i })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Malformed result session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("session-malformed-result");
|
||||
expect(screen.getByRole("alert")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
|
||||
});
|
||||
|
||||
it("re-syncs the selected session to the recoverable error view when the modal reopens", async () => {
|
||||
const reopenedSummary: PlanningSummary = {
|
||||
title: "Reopen then recover",
|
||||
description: "First open shows a valid summary",
|
||||
suggestedSize: "S",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Recover"],
|
||||
};
|
||||
|
||||
mockFetchAiSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-reopen-recover",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Reopen recover session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession
|
||||
.mockResolvedValueOnce({
|
||||
id: "session-reopen-recover",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Reopen recover session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Reopen recover session" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(reopenedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "session-reopen-recover",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Reopen recover session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Reopen recover session" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: "{",
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Reopen recover session/i })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Reopen recover session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<PlanningModeModal
|
||||
isOpen={false}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenLastCalledWith("session-reopen-recover");
|
||||
expect(screen.getByRole("alert")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
|
||||
});
|
||||
|
||||
it("quietly falls back to the initial view when a resumed session no longer exists", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce(null);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-deleted"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText("Failed to load session")).toBeNull();
|
||||
});
|
||||
|
||||
it("quietly falls back to the initial view when a sidebar session no longer exists", async () => {
|
||||
mockFetchAiSessions.mockResolvedValueOnce([
|
||||
{
|
||||
id: "session-sidebar-deleted",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Sidebar deleted session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession.mockResolvedValueOnce(null);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Sidebar deleted session/i })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Sidebar deleted session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-deleted");
|
||||
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText("Failed to load session")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a task from a resumed complete session and keeps the completed session in local history", async () => {
|
||||
|
||||
Reference in New Issue
Block a user