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:
@@ -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