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:
gsxdsm
2026-06-02 18:58:48 -07:00
parent 3d18872f98
commit 38b84a36f4
3 changed files with 305 additions and 11 deletions

View File

@@ -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],

View File

@@ -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 () => {