fix(planning): retry interrupted sessions on return

Recover persisted, SSE, and polled stream failures through the existing bounded retry path while preventing stale sessions from taking ownership.
This commit is contained in:
gsxdsm
2026-07-21 10:44:51 -07:00
parent 76cecacddd
commit 38455359f2
3 changed files with 291 additions and 73 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Automatically retry interrupted Planning sessions when operators return to them.
category: fix
dev: Uses session-scoped retry ownership across persisted, polled, and SSE error recovery.

View File

@@ -453,20 +453,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
*/ */
const startPlanningInFlightRef = useRef(false); const startPlanningInFlightRef = useRef(false);
/* /*
FNXC:PlanningRetry 2026-07-15-00:00: FNXC:PlanningRetry 2026-07-21-10:00:
FN-8332 permits automatic retry only for a generation this mounted Planning Persisted, polled, and SSE-reported Planning stream errors share one bounded retry path. Key
Mode instance started. A reloaded session may reconnect to observe a server in-flight ownership to the session and invocation token so a stale load/retry cannot clear or
turn, but its persisted error must stay manual instead of spending another mutate the newly selected session, while successful progress still resets the three-attempt budget.
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.
*/ */
const planningAutoRetryAttemptRef = useRef(0); const planningAutoRetryAttemptRef = useRef(0);
const planningAutoRetryInFlightRef = useRef(false); const planningAutoRetryOwnerRef = useRef<{ sessionId: string; token: symbol } | null>(null);
const startPlanningAutoRetryRef = useRef<(sessionId: string, errorMessage: string) => Promise<boolean>>(async () => false); const startPlanningAutoRetryRef = useRef<(sessionId: string) => Promise<boolean>>(async () => false);
const planningSessionLoadEpochRef = useRef(0);
/* /*
FNXC:PlanningMode 2026-07-02-07:56: FNXC:PlanningMode 2026-07-02-07:56:
Refine Further is a single-flight completed-summary turn. Guard synchronously with a ref so duplicate click, touch, or keyboard activations cannot submit a second refine request or close the active stream with a generation-in-progress error before React renders the disabled state. Refine Further is a single-flight completed-summary turn. Guard synchronously with a ref so duplicate click, touch, or keyboard activations cannot submit a second refine request or close the active stream with a generation-in-progress error before React renders the disabled state.
@@ -519,12 +514,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}; };
}, [isOpen, newSessionFocusSignal]); }, [isOpen, newSessionFocusSignal]);
const resetPlanningAutoRetryBudget = useCallback(() => { const resetPlanningAutoRetryAttempts = useCallback(() => {
planningAutoRetryAttemptRef.current = 0; planningAutoRetryAttemptRef.current = 0;
planningAutoRetryInFlightRef.current = false;
setAutoRetryAttempt(0); setAutoRetryAttempt(0);
setIsAutoRetrying(false); setIsAutoRetrying(false);
}, []); }, []);
const resetPlanningAutoRetryBudget = useCallback(() => {
resetPlanningAutoRetryAttempts();
planningAutoRetryOwnerRef.current = null;
}, [resetPlanningAutoRetryAttempts]);
/* /*
FNXC:PlanningMultiTab 2026-07-14-00:00: FNXC:PlanningMultiTab 2026-07-14-00:00:
Planning Mode has no cross-tab coordination. The persisted session row is the single source Planning Mode has no cross-tab coordination. The persisted session row is the single source
@@ -915,8 +914,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput(""); setStreamingOutput("");
} else if (session.status === "error") { } else if (session.status === "error") {
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
const handled = liveGenerationSessionIdRef.current === sessionId const handled = await startPlanningAutoRetryRef.current(sessionId);
&& await startPlanningAutoRetryRef.current(sessionId, errorMessage);
if (handled) return; if (handled) return;
if (cancelled || currentSessionIdRef.current !== sessionId) return; if (cancelled || currentSessionIdRef.current !== sessionId) return;
/* /*
@@ -982,6 +980,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelId(undefined); setPlanningModelId(undefined);
setPlanningThinkingLevel(""); setPlanningThinkingLevel("");
currentSessionIdRef.current = null; currentSessionIdRef.current = null;
planningSessionLoadEpochRef.current += 1;
startPlanningInFlightRef.current = false; startPlanningInFlightRef.current = false;
}, [resetPlanningAutoRetryBudget]); }, [resetPlanningAutoRetryBudget]);
@@ -1177,16 +1176,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (isStaleEvent()) return; if (isStaleEvent()) return;
/* /*
FNXC:PlanningRetry 2026-07-15-00:00: FNXC:PlanningRetry 2026-07-21-10:00:
FN-8332 limits the stuck-turn retry budget to generations started by A stream failure is recoverable regardless of which mount started the generation.
this mounted UI. A resumed stream may observe a terminal persisted Returning to Planning must use the same bounded, single-flight retry path as a live
error, but it must surface the manual Retry/Dismiss panel instead; turn so tab suspension or navigation never turns a resumable session into an error UI.
overlapping live SSE and poll signals still share the single-flight guard.
*/ */
if ( if (await startPlanningAutoRetryRef.current(sessionId)) {
liveGenerationSessionIdRef.current === sessionId
&& await startPlanningAutoRetryRef.current(sessionId, errorMessage)
) {
return; return;
} }
setIsRetrying(false); setIsRetrying(false);
@@ -1224,21 +1219,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
); );
const startPlanningRetry = useCallback( const startPlanningRetry = useCallback(
async (retryTarget: { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null }, options: { auto: boolean }) => { async (
retryTarget: { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null },
options: { auto: boolean; retryToken?: symbol },
) => {
setError(null); setError(null);
setIsRetrying(!options.auto); setIsRetrying(!options.auto);
setIsAutoRetrying(options.auto); setIsAutoRetrying(options.auto);
setStreamingOutput(""); setStreamingOutput("");
setGenerationStartTime(Date.now()); setGenerationStartTime(Date.now());
viewRef.current = { type: "loading" };
setView({ type: "loading" }); setView({ type: "loading" });
currentSessionIdRef.current = retryTarget.sessionId; currentSessionIdRef.current = retryTarget.sessionId;
liveGenerationSessionIdRef.current = retryTarget.sessionId;
connectToPlanningStream(retryTarget.sessionId); connectToPlanningStream(retryTarget.sessionId);
try { try {
await retryPlanningSession(retryTarget.sessionId, projectId); await retryPlanningSession(retryTarget.sessionId, projectId);
} catch (err) { } catch (err) {
const retryStillOwnsSession = () => currentSessionIdRef.current === retryTarget.sessionId
&& (!options.auto || planningAutoRetryOwnerRef.current?.token === options.retryToken);
if (!retryStillOwnsSession()) return;
let retryError: unknown = err; let retryError: unknown = err;
const retryErrorMessage = getErrorMessage(err) || ""; const retryErrorMessage = getErrorMessage(err) || "";
@@ -1248,6 +1250,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!session) { if (!session) {
throw new Error("Failed to refresh planning session."); throw new Error("Failed to refresh planning session.");
} }
if (!retryStillOwnsSession()) return;
currentSessionIdRef.current = session.id; currentSessionIdRef.current = session.id;
@@ -1301,11 +1304,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}); });
} }
} else if (session.status === "error") { } else if (session.status === "error") {
setView({ const terminalView: ViewState = {
type: "error", type: "error",
session: { sessionId: session.id, currentQuestion: null, summary: null }, session: { sessionId: session.id, currentQuestion: null, summary: null },
errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."), errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."),
}); };
viewRef.current = terminalView;
setView(terminalView);
setIsAutoRetrying(false); setIsAutoRetrying(false);
} }
@@ -1315,45 +1320,60 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} }
} }
if (!retryStillOwnsSession()) return;
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
setView({ if (options.auto && planningAutoRetryAttemptRef.current < MAX_PLANNING_AUTO_RETRIES) {
viewRef.current = { type: "loading" };
setView({ type: "loading" });
setIsAutoRetrying(true);
queueMicrotask(() => {
if (currentSessionIdRef.current === retryTarget.sessionId) {
void startPlanningAutoRetryRef.current(retryTarget.sessionId);
}
});
return;
}
const terminalView: ViewState = {
type: "error", type: "error",
session: retryTarget, session: retryTarget,
errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."), errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."),
}); };
viewRef.current = terminalView;
setView(terminalView);
setIsAutoRetrying(false); setIsAutoRetrying(false);
} finally { } finally {
if (!options.auto) { if (!options.auto) {
setIsRetrying(false); setIsRetrying(false);
} }
planningAutoRetryInFlightRef.current = false; if (options.retryToken && planningAutoRetryOwnerRef.current?.token === options.retryToken) {
planningAutoRetryOwnerRef.current = null;
}
} }
}, },
[connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t], [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t],
); );
const startPlanningAutoRetry = useCallback( const startPlanningAutoRetry = useCallback(
async (sessionId: string, _errorMessage: string) => { async (sessionId: string) => {
if (viewRef.current.type === "error") { if (planningAutoRetryOwnerRef.current?.sessionId === sessionId) {
return false;
}
if (planningAutoRetryInFlightRef.current) {
return true; return true;
} }
if (viewRef.current.type === "error") return false;
if (planningAutoRetryAttemptRef.current >= MAX_PLANNING_AUTO_RETRIES) { if (planningAutoRetryAttemptRef.current >= MAX_PLANNING_AUTO_RETRIES) {
setIsAutoRetrying(false); setIsAutoRetrying(false);
return false; return false;
} }
const attempt = planningAutoRetryAttemptRef.current + 1; const attempt = planningAutoRetryAttemptRef.current + 1;
const retryToken = Symbol(`planning-auto-retry:${sessionId}:${attempt}`);
planningAutoRetryAttemptRef.current = attempt; planningAutoRetryAttemptRef.current = attempt;
planningAutoRetryInFlightRef.current = true; planningAutoRetryOwnerRef.current = { sessionId, token: retryToken };
setAutoRetryAttempt(attempt); setAutoRetryAttempt(attempt);
setIsAutoRetrying(true); setIsAutoRetrying(true);
await startPlanningRetry( await startPlanningRetry(
{ sessionId, currentQuestion: null, summary: null }, { sessionId, currentQuestion: null, summary: null },
{ auto: true }, { auto: true, retryToken },
); );
return true; return true;
}, },
@@ -1426,7 +1446,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
); );
draftSessionIdRef.current = null; draftSessionIdRef.current = null;
currentSessionIdRef.current = sessionId; currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
setSelectedSessionId(sessionId); setSelectedSessionId(sessionId);
setShowSessionList(false); setShowSessionList(false);
setMobileShowDetail(true); setMobileShowDetail(true);
@@ -1497,10 +1516,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// Load a specific persisted session into the right pane. // Load a specific persisted session into the right pane.
const loadSession = useCallback( const loadSession = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
const loadEpoch = ++planningSessionLoadEpochRef.current;
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
// Loading a database row never makes its in-flight turn local to this mount. currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = null;
setError(null); setError(null);
setStreamingOutput(""); setStreamingOutput("");
@@ -1514,10 +1533,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsRefiningSummary(false); setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false; refineSummaryInFlightRef.current = false;
setGenerationStartTime(null); setGenerationStartTime(null);
viewRef.current = { type: "loading" };
setView({ type: "loading" }); setView({ type: "loading" });
try { try {
const session = await fetchAiSession(sessionId); const session = await fetchAiSession(sessionId);
if (planningSessionLoadEpochRef.current !== loadEpoch || currentSessionIdRef.current !== sessionId) return;
if (!session) { if (!session) {
// The session was deleted (commonly: this tab just turned it into // The session was deleted (commonly: this tab just turned it into
// tasks via Create Task / Create Tasks). Quietly fall back to the // tasks via Create Task / Create Tasks). Quietly fall back to the
@@ -1596,16 +1617,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} }
if (session.status === "error") { if (session.status === "error") {
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
/* /*
FNXC:PlanningRetry 2026-07-15-00:00: FNXC:PlanningRetry 2026-07-21-10: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. Persisted stream errors are retryable work, not a terminal Planning destination. Re-enter
through the same generation retry path used by live stream failures while preserving the
hydrated running plan and the existing bounded single-flight protection.
*/ */
setView({ if (planningAutoRetryOwnerRef.current?.sessionId !== sessionId) {
type: "error", resetPlanningAutoRetryAttempts();
session: { sessionId, currentQuestion: null, summary: null }, }
errorMessage, await startPlanningAutoRetry(sessionId);
});
return; return;
} }
@@ -1692,6 +1713,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
connectToPlanningStream(sessionId); connectToPlanningStream(sessionId);
} }
} catch (err) { } catch (err) {
if (planningSessionLoadEpochRef.current !== loadEpoch || currentSessionIdRef.current !== sessionId) return;
currentSessionIdRef.current = sessionId; currentSessionIdRef.current = sessionId;
setActivePlanPrompt(""); setActivePlanPrompt("");
setError(null); setError(null);
@@ -1702,7 +1724,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}); });
} }
}, },
[connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t], [connectToPlanningStream, projectId, resetPlanningAutoRetryAttempts, resetPlanningAutoRetryBudget, startPlanningAutoRetry, t],
); );
// Resume the externally-requested session when the modal first opens. // Resume the externally-requested session when the modal first opens.
@@ -2278,7 +2300,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "loading" }); setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state setStreamingOutput(""); // Clear old thinking output when entering loading state
currentSessionIdRef.current = sessionId; currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
if (!streamConnectionRef.current?.isConnected()) { if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(sessionId); connectToPlanningStream(sessionId);
} }
@@ -2375,7 +2396,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const history = conversationHistoryRef.current; const history = conversationHistoryRef.current;
currentSessionIdRef.current = null; currentSessionIdRef.current = null;
liveGenerationSessionIdRef.current = null; planningSessionLoadEpochRef.current += 1;
streamConnectionEpochRef.current += 1; streamConnectionEpochRef.current += 1;
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
@@ -2450,7 +2471,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} }
} }
currentSessionIdRef.current = sessionId; currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
if (!streamConnectionRef.current?.isConnected()) { if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(sessionId); connectToPlanningStream(sessionId);
} }

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { PlanningModeModal } from "../PlanningModeModal"; import { PlanningModeModal } from "../PlanningModeModal";
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers"; import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile")); const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile"));
const mockConnectPlanningStream = vi.hoisted(() => vi.fn()); const mockConnectPlanningStream = vi.hoisted(() => vi.fn());
@@ -24,12 +24,12 @@ vi.mock("../../api", () => {
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args), fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args), respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }), fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message, startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: (...args: unknown[]) => mockRetryPlanningSession(...args), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
}; };
}); });
const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" }; const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
function renderSession(session: Record<string, unknown>) { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); } function renderSession() { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
const summaryWithRefinements = { const summaryWithRefinements = {
...mockSummary, ...mockSummary,
description: "Build a **reviewed** recovery workflow with an operator [runbook](https://example.com/runbook).", description: "Build a **reviewed** recovery workflow with an operator [runbook](https://example.com/runbook).",
@@ -39,7 +39,198 @@ const summaryWithRefinements = {
}; };
describe("PlanningModeModal sequential flow", () => { describe("PlanningModeModal sequential flow", () => {
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); }); beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockRetryPlanningSession.mockResolvedValue({ success: true }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
/*
FNXC:PlanningRetry 2026-07-21-10:00:
Returning to Planning must recover every failed generation surface instead of rendering a
terminal error: both a row already persisted as error and a resumed generating stream that
subsequently reports its durable error dispatch the existing retry endpoint automatically.
*/
it("automatically retries a persisted error when returning to Planning", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "error",
error: "The planning stream was interrupted",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
renderSession();
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
expect(screen.queryByText("The planning stream was interrupted")).toBeNull();
});
it("automatically retries a stream error after returning to a generating session", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
});
renderSession();
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
mockFetchAiSession.mockResolvedValue({
...base,
status: "error",
error: "The resumed stream failed",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
mockConnectPlanningStream.mock.calls[0]?.[2]?.onError?.("The resumed stream failed");
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
expect(screen.queryByText("The resumed stream failed")).toBeNull();
});
it("retries all bounded attempts before surfacing a returned stream error", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "error",
error: "The planning stream was interrupted",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
mockRetryPlanningSession.mockRejectedValue(new Error("Temporary retry outage"));
renderSession();
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3));
expect(await screen.findByText("Temporary retry outage")).toBeInTheDocument();
mockConnectPlanningStream.mock.calls.at(-1)?.[2]?.onError?.("Late terminal stream error");
await act(async () => Promise.resolve());
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3);
expect(screen.getByText("Late terminal stream error")).toBeInTheDocument();
});
it("coalesces overlapping stream errors into one retry request", async () => {
let resolveRetry!: (value: { success: true }) => void;
mockRetryPlanningSession.mockReturnValue(new Promise((resolve) => {
resolveRetry = resolve;
}));
mockFetchAiSession.mockResolvedValue({
...base,
status: "error",
error: "The planning stream was interrupted",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
renderSession();
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1));
mockConnectPlanningStream.mock.calls[0]?.[2]?.onError?.("Duplicate stream error");
await act(async () => Promise.resolve());
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1);
resolveRetry({ success: true });
});
it("ignores a stale errored load after a newer session is selected", async () => {
const resolvers = new Map<string, (session: Record<string, unknown>) => void>();
mockFetchAiSession.mockImplementation((sessionId: string) => new Promise((resolve) => {
resolvers.set(sessionId, resolve);
}));
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
const { rerender } = render(<PlanningModeModal {...props} resumeSessionId="session-a" />);
await waitFor(() => expect(resolvers.has("session-a")).toBe(true));
rerender(<PlanningModeModal {...props} resumeSessionId="session-b" />);
await waitFor(() => expect(resolvers.has("session-b")).toBe(true));
resolvers.get("session-b")?.({
...base,
id: "session-b",
status: "error",
error: "Session B stream failed",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-b", "project-1"));
resolvers.get("session-a")?.({
...base,
id: "session-a",
status: "error",
error: "Session A stream failed",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
await act(async () => Promise.resolve());
expect(mockRetryPlanningSession).not.toHaveBeenCalledWith("session-a", "project-1");
});
it("keeps a stale retry completion from taking ownership from the newer session", async () => {
let resolveSessionARetry!: (value: { success: true }) => void;
mockRetryPlanningSession.mockImplementation((sessionId: string) => sessionId === "session-a"
? new Promise((resolve) => {
resolveSessionARetry = resolve;
})
: Promise.resolve({ success: true }));
mockFetchAiSession.mockImplementation(async (sessionId: string) => ({
...base,
id: sessionId,
status: "error",
error: `${sessionId} stream failed`,
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
}));
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
const { rerender } = render(<PlanningModeModal {...props} resumeSessionId="session-a" />);
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-a", "project-1"));
rerender(<PlanningModeModal {...props} resumeSessionId="session-b" />);
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-b", "project-1"));
const connectionCountForB = mockConnectPlanningStream.mock.calls.filter(([sessionId]) => sessionId === "session-b").length;
resolveSessionARetry({ success: true });
await act(async () => Promise.resolve());
expect(mockConnectPlanningStream.mock.calls.at(-1)?.[0]).toBe("session-b");
expect(mockConnectPlanningStream.mock.calls.filter(([sessionId]) => sessionId === "session-b")).toHaveLength(connectionCountForB);
expect(screen.queryByText("session-a stream failed")).toBeNull();
});
it("automatically retries a resumed error discovered by the loading poll", async () => {
const intervalSpy = vi.spyOn(globalThis, "setInterval");
mockFetchAiSession.mockResolvedValue({
...base,
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
});
renderSession();
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
const poll = intervalSpy.mock.calls.find(([, delay]) => delay === 8000)?.[0];
expect(poll).toBeTypeOf("function");
mockFetchAiSession.mockResolvedValue({
...base,
status: "error",
error: "Poll observed stream error",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
await act(async () => {
await (poll as () => Promise<void>)();
});
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1");
expect(screen.queryByText("Poll observed stream error")).toBeNull();
intervalSpy.mockRestore();
});
it("persists a draft before generation and immediately shows initial-plan progress", async () => { it("persists a draft before generation and immediately shows initial-plan progress", async () => {
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />); render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } }); fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
@@ -57,7 +248,7 @@ describe("PlanningModeModal sequential flow", () => {
result: JSON.stringify(summaryWithRefinements), result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }),
}); });
renderSession({}); renderSession();
const workspace = await screen.findByTestId("planning-workspace"); const workspace = await screen.findByTestId("planning-workspace");
expect(workspace).toHaveTextContent("Build authentication system"); expect(workspace).toHaveTextContent("Build authentication system");
expect(workspace).toHaveTextContent("Which outcome matters most?"); expect(workspace).toHaveTextContent("Which outcome matters most?");
@@ -90,7 +281,7 @@ describe("PlanningModeModal sequential flow", () => {
result: JSON.stringify(summaryWithRefinements), result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}", inputPayload: "{}",
}); });
renderSession({}); renderSession();
expect(await screen.findByText("Old question?")).toBeInTheDocument(); expect(await screen.findByText("Old question?")).toBeInTheDocument();
mockFetchAiSession.mockResolvedValue({ mockFetchAiSession.mockResolvedValue({
@@ -128,7 +319,7 @@ describe("PlanningModeModal sequential flow", () => {
}]), }]),
inputPayload: "{}", inputPayload: "{}",
}); });
renderSession({}); renderSession();
const sessionsButton = await screen.findByRole("button", { name: "Sessions" }); const sessionsButton = await screen.findByRole("button", { name: "Sessions" });
const historyButton = screen.getByRole("button", { name: "History" }); const historyButton = screen.getByRole("button", { name: "History" });
@@ -198,7 +389,7 @@ describe("PlanningModeModal sequential flow", () => {
.mockRejectedValueOnce(Object.assign(new Error("Planning task creation is already in progress"), { status: 409 })) .mockRejectedValueOnce(Object.assign(new Error("Planning task creation is already in progress"), { status: 409 }))
.mockResolvedValueOnce({ id: "FN-8442" }); .mockResolvedValueOnce({ id: "FN-8442" });
renderSession({}); renderSession();
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" })); fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442"); expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442");
@@ -266,7 +457,7 @@ describe("PlanningModeModal sequential flow", () => {
result: JSON.stringify(summaryWithRefinements), result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}", inputPayload: "{}",
}); });
renderSession({}); renderSession();
const workspace = await screen.findByTestId("planning-workspace"); const workspace = await screen.findByTestId("planning-workspace");
// The viewport-mode hook is mocked without changing jsdom's CSS media viewport. // The viewport-mode hook is mocked without changing jsdom's CSS media viewport.
@@ -296,7 +487,7 @@ describe("PlanningModeModal sequential flow", () => {
inputPayload: "{}", inputPayload: "{}",
}); });
mockRespondToPlanning.mockReturnValue(new Promise(() => undefined)); mockRespondToPlanning.mockReturnValue(new Promise(() => undefined));
renderSession({}); renderSession();
fireEvent.click(await screen.findByLabelText("Secure defaults")); fireEvent.click(await screen.findByLabelText("Secure defaults"));
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
const workspace = screen.getByTestId("planning-workspace"); const workspace = screen.getByTestId("planning-workspace");
@@ -327,7 +518,7 @@ describe("PlanningModeModal sequential flow", () => {
}, },
summary: summaryWithRefinements, summary: summaryWithRefinements,
}); });
renderSession({}); renderSession();
fireEvent.click(await screen.findByRole("button", { name: "Refine" })); fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
expect(screen.getByTestId("planning-plan-pane")).toHaveTextContent("Build authentication system"); expect(screen.getByTestId("planning-plan-pane")).toHaveTextContent("Build authentication system");
expect(screen.getByTestId("planning-question-pane")).toHaveTextContent("What should the plan prioritize?"); expect(screen.getByTestId("planning-question-pane")).toHaveTextContent("What should the plan prioritize?");
@@ -349,7 +540,7 @@ describe("PlanningModeModal sequential flow", () => {
}); });
it("restores the updating-plan progress state after refresh", async () => { it("restores the updating-plan progress state after refresh", async () => {
mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) }); mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) });
renderSession({}); renderSession();
expect(await screen.findByText("Generating plan…")).toBeInTheDocument(); expect(await screen.findByText("Generating plan…")).toBeInTheDocument();
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object)); expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object));
@@ -390,7 +581,7 @@ describe("PlanningModeModal sequential flow", () => {
conversationHistory: JSON.stringify([{ question: priorQuestion, response: { "q-prior": "Preserve drafts" } }]), conversationHistory: JSON.stringify([{ question: priorQuestion, response: { "q-prior": "Preserve drafts" } }]),
inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }), inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }),
}); });
renderSession({}); renderSession();
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object))); await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object)));
fireEvent.click(await screen.findByRole("button", { name: "Stop" })); fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
@@ -443,7 +634,7 @@ describe("PlanningModeModal sequential flow", () => {
currentQuestion: { id: "q-refined", type: "text", question: "Which refined area comes first?" }, currentQuestion: { id: "q-refined", type: "text", question: "Which refined area comes first?" },
summary: summaryWithRefinements, summary: summaryWithRefinements,
}); });
renderSession({}); renderSession();
fireEvent.click(await screen.findByRole("button", { name: "Stop" })); fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
fireEvent.click(await screen.findByRole("button", { name: "Refine" })); fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
@@ -469,7 +660,7 @@ describe("PlanningModeModal sequential flow", () => {
currentQuestion: { id: "q-replaced", type: "text", question: "What should the replacement prioritize?" }, currentQuestion: { id: "q-replaced", type: "text", question: "What should the replacement prioritize?" },
summary: summaryWithRefinements, summary: summaryWithRefinements,
}); });
renderSession({}); renderSession();
fireEvent.click(await screen.findByRole("button", { name: "Refine" })); fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Replace the current direction." } }); fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Replace the current direction." } });
@@ -500,14 +691,14 @@ describe("PlanningModeModal sequential flow", () => {
result: JSON.stringify(summaryWithRefinements), result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}", inputPayload: "{}",
}); });
renderSession({}); renderSession();
expect(await screen.findByText("What should come next?")).toBeInTheDocument(); expect(await screen.findByText("What should come next?")).toBeInTheDocument();
expect(screen.getAllByText("Other (write your own)")).toHaveLength(1); expect(screen.getAllByText("Other (write your own)")).toHaveLength(1);
}); });
it("keeps detailed plan review and freeform refinement available on mobile", async () => { it("keeps detailed plan review and freeform refinement available on mobile", async () => {
mockViewportMode.mockReturnValue("mobile"); mockViewportMode.mockReturnValue("mobile");
mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" }); mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
renderSession({}); renderSession();
expect(await screen.findByText("What to change")).toBeInTheDocument(); expect(await screen.findByText("What to change")).toBeInTheDocument();
expect(screen.getByText("Acceptance criteria")).toBeInTheDocument(); expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "Security boundaries" })).toBeNull(); expect(screen.queryByRole("checkbox", { name: "Security boundaries" })).toBeNull();
@@ -525,7 +716,7 @@ describe("PlanningModeModal sequential flow", () => {
}); });
it("restores a validated unlinked session to create-only retry", async () => { it("restores a validated unlinked session to create-only retry", async () => {
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) }); mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
renderSession({}); renderSession();
expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument(); expect(await screen.findByTestId("planning-create-retry")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Proceed with plan" })).toBeNull(); expect(screen.queryByRole("button", { name: "Proceed with plan" })).toBeNull();
}); });