FN-7946: auto-retry stuck Planning Mode AI generation up to 3 times

Planning Mode now automatically retries a stuck or terminally-errored AI
generation session up to three times before falling back to the permanent
Retry/Dismiss error panel, reducing manual retries for transient failures.

- Add a bounded (MAX_PLANNING_AUTO_RETRIES = 3) client-side auto-retry that
  reuses the existing /planning/:id/retry endpoint whenever the SSE stream's
  onError, a session reload, or the stuck-session poll observes a terminal
  "error" status.
- Track the retry budget in refs (planningAutoRetryAttemptRef,
  planningAutoRetryInFlightRef) so async SSE/poll/loadSession handlers share
  a single in-flight guard, with the current attempt mirrored into state
  (isAutoRetrying/autoRetryAttempt) for the UI.
- Reset the retry budget whenever the session makes real progress (reaches
  a new question or a completed summary), and surface the permanent
  Retry/Dismiss error view once the budget is exhausted.
- Show a "Retrying... (attempt N of 3)" loading message while an automatic
  retry is in flight, distinct from the manual Retry button state.
- Fix a stuck-poll edge case where a terminal error discovered only by the
  poll (missed SSE event) after the auto-retry budget was exhausted left
  the modal spinning on "Generating next question..." forever instead of
  showing the error view.
- Document the new auto-retry behavior in docs/dashboard-guide.md and add a
  minor changeset for @runfusion/fusion.
- Extend PlanningModeModal.planning-flow.test.tsx with coverage for the
  auto-retry budget, single-flight behavior, and the poll-discovered
  terminal-error fallback.

Files changed:
 .changeset/fn-7946-planning-auto-retry.md          |   7 +
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/PlanningModeModal.tsx | 339 ++++++++++++++------
 .../PlanningModeModal.planning-flow.test.tsx       | 353 ++++++++++++++++++---
 4 files changed, 567 insertions(+), 135 deletions(-)

Fusion-Task-Id: FN-7946
Fusion-Task-Lineage: 42e911dc-9639-46ab-bb4f-bc9060413140
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-13 09:50:48 -07:00
parent f0888d43c3
commit 7cc622bed2
4 changed files with 567 additions and 135 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Planning Mode now auto-retries a stuck AI generation up to 3 times before showing an error.
category: feature
dev: Bounded client-side auto-retry in PlanningModeModal reusing the existing /planning/:id/retry endpoint; counter resets on successful progress and is single-flighted across SSE onError, reopen, and the stuck poll.

View File

@@ -454,6 +454,9 @@ Planning is a desktop/tablet left-sidebar main-content destination after **Comma
When a Planning session is awaiting your input, look for the yellow needs-input dot on the Planning nav destination (desktop left sidebar; mobile More sheet item and More tab icon) rather than a banner — clicking Planning always opens the correct docked Planning view.
<!-- 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:PlanningModeDeepeningCheckpoint 2026-07-02-12:18: Planning Mode must pause before every final summary at a mandatory "Would you like to go deeper?" checkpoint so users can request inferred follow-up themes, enter a custom topic, or proceed without deepening. -->
<!-- FNXC:PlanningMode 2026-07-05-00:25: The planning AI now proposes plan-specific deepening themes (deepeningThemes on the completion payload) so this checkpoint surfaces topics tailored to the user's actual plan, including angles they had not anticipated, instead of a fixed generic set. The regex-derived generic themes remain the fallback whenever the AI supplies none (FN-7616 / issue #1912). -->

View File

@@ -76,6 +76,8 @@ const PLANNING_SIDEBAR_MIN_WIDTH = 220;
const PLANNING_SIDEBAR_MAX_WIDTH = 560;
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
const MAX_PLANNING_AUTO_RETRIES = 3;
interface PlanningModeModalProps {
isOpen: boolean;
onClose: () => void;
@@ -320,6 +322,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [isAutoRetrying, setIsAutoRetrying] = useState(false);
const [autoRetryAttempt, setAutoRetryAttempt] = useState(0);
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false);
@@ -351,6 +355,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const modalRef = useRef<HTMLDivElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const viewRef = useRef<ViewState>({ type: "initial" });
/*
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 planningAutoRetryInFlightRef = useRef(false);
const startPlanningAutoRetryRef = useRef<(sessionId: string, errorMessage: string) => Promise<boolean>>(async () => false);
/*
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.
@@ -370,6 +382,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// yanks the user back into the previous session's question view.
const dismissedResumeRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
useEffect(() => {
viewRef.current = view;
}, [view]);
const resetPlanningAutoRetryBudget = useCallback(() => {
planningAutoRetryAttemptRef.current = 0;
planningAutoRetryInFlightRef.current = false;
setAutoRetryAttempt(0);
setIsAutoRetrying(false);
}, []);
const sessionTabId = useMemo(() => getSessionTabId(), []);
const {
isLockedByOther,
@@ -589,6 +612,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (cancelled || !session) return;
if (currentSessionIdRef.current !== sessionId) return;
if (session.status === "awaiting_input" && session.currentQuestion) {
resetPlanningAutoRetryBudget();
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
setView({
type: "question",
@@ -596,6 +620,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
});
setStreamingOutput("");
} else if (session.status === "complete" && session.result) {
resetPlanningAutoRetryBudget();
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
setView({
type: "summary",
@@ -604,6 +629,46 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
});
setEditedSummary(summary);
setStreamingOutput("");
} else if (session.status === "error") {
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
const handled = await startPlanningAutoRetryRef.current(sessionId, errorMessage);
if (handled) return;
if (cancelled || currentSessionIdRef.current !== sessionId) return;
/*
FNXC:PlanningRetry 2026-07-13-00:05:
Mirror the SSE onError terminal-error transition here: when this poll is the one that
discovers a terminal session error (missed SSE event) and the auto-retry budget is
already exhausted, startPlanningAutoRetryRef resolves false and previously nothing
transitioned the view out of "loading" — the modal was stuck spinning on
"Generating next question..." forever, re-polling every 8s with no visible progress.
Build the permanent error view exactly like connectToPlanningStream's onError does.
*/
setIsRetrying(false);
setIsAutoRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setError(null);
setView((prev) => {
if (prev.type === "question" || prev.type === "summary" || prev.type === "error") {
return { type: "error", session: prev.session, errorMessage };
}
return {
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage,
};
});
setStreamingOutput("");
broadcastUpdate({
sessionId,
status: "error",
needsInput: false,
owningTabId: sessionTabId,
type: "planning",
title: initialPlan.trim() || undefined,
projectId: projectId ?? null,
});
broadcastCompleted({ sessionId, status: "error" });
}
} catch {
// best-effort; keep polling
@@ -615,7 +680,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
cancelled = true;
clearInterval(interval);
};
}, [view.type]);
}, [broadcastCompleted, broadcastUpdate, initialPlan, lockSessionId, projectId, resetPlanningAutoRetryBudget, sessionTabId, t, view.type]);
const resetDetailState = useCallback(() => {
setInitialPlan("");
@@ -630,6 +695,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setPlanningModelProvider(undefined);
@@ -639,7 +705,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setCustomQuestionCount("");
currentSessionIdRef.current = null;
setLockSessionId(null);
}, []);
}, [resetPlanningAutoRetryBudget]);
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
@@ -744,6 +810,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const normalizedQuestion = normalizeQuestionOptions(question);
setIsReconnecting(false);
setIsRetrying(false);
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
clearPlanningDescription(projectId);
@@ -785,6 +852,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const normalizedSummary = normalizePlanningSummary(summary);
setIsReconnecting(false);
setIsRetrying(false);
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
clearPlanningDescription(projectId);
@@ -841,7 +909,15 @@ 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.
*/
if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) {
return;
}
setIsRetrying(false);
setIsAutoRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setError(null);
@@ -873,6 +949,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
currentSessionIdRef.current = null;
@@ -885,9 +962,141 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
streamConnectionRef.current = connection;
},
[broadcastCompleted, broadcastUpdate, initialPlan, projectId, sessionTabId],
[broadcastCompleted, broadcastUpdate, initialPlan, projectId, resetPlanningAutoRetryBudget, sessionTabId],
);
const startPlanningRetry = useCallback(
async (retryTarget: { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null }, options: { auto: boolean }) => {
setError(null);
setIsRetrying(!options.auto);
setIsAutoRetrying(options.auto);
setStreamingOutput("");
setView({ type: "loading" });
currentSessionIdRef.current = retryTarget.sessionId;
setLockSessionId(retryTarget.sessionId);
connectToPlanningStream(retryTarget.sessionId);
try {
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
} catch (err) {
let retryError: unknown = err;
const retryErrorMessage = getErrorMessage(err) || "";
if (retryErrorMessage.includes("not in an error state")) {
try {
const session = await fetchAiSession(retryTarget.sessionId);
if (!session) {
throw new Error("Failed to refresh planning session.");
}
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
if (session.status === "generating") {
setStreamingOutput(session.thinkingOutput ?? "");
setView({ type: "loading" });
} else if (session.status === "awaiting_input") {
if (!session.currentQuestion) {
throw new Error("Planning session is awaiting input but has no current question.");
}
resetPlanningAutoRetryBudget();
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion) as PlanningQuestion);
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId: session.id, currentQuestion: question, summary: null },
});
if (session.thinkingOutput) {
const trimmed = session.thinkingOutput.trim();
if (trimmed) {
setConversationHistory((prev) => {
const lastEntry = prev[prev.length - 1];
if (lastEntry?.thinkingOutput === trimmed) return prev;
return [...prev, { thinkingOutput: trimmed }];
});
}
}
if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(session.id);
}
} else if (session.status === "complete") {
if (!session.result) {
throw new Error("Planning session is complete but has no result.");
}
resetPlanningAutoRetryBudget();
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId: session.id, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
} else if (session.status === "error") {
setView({
type: "error",
session: { sessionId: session.id, currentQuestion: null, summary: null },
errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."),
});
setIsAutoRetrying(false);
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError) {
retryError = sessionRefreshError;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
session: retryTarget,
errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."),
});
setIsReconnecting(false);
setIsAutoRetrying(false);
} finally {
if (!options.auto) {
setIsRetrying(false);
}
planningAutoRetryInFlightRef.current = false;
}
},
[connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, sessionTabId, t],
);
const startPlanningAutoRetry = useCallback(
async (sessionId: string, _errorMessage: string) => {
if (viewRef.current.type === "error") {
return false;
}
if (planningAutoRetryInFlightRef.current) {
return true;
}
if (planningAutoRetryAttemptRef.current >= MAX_PLANNING_AUTO_RETRIES) {
setIsAutoRetrying(false);
return false;
}
const attempt = planningAutoRetryAttemptRef.current + 1;
planningAutoRetryAttemptRef.current = attempt;
planningAutoRetryInFlightRef.current = true;
setAutoRetryAttempt(attempt);
setIsAutoRetrying(true);
await startPlanningRetry(
{ sessionId, currentQuestion: null, summary: null },
{ auto: true },
);
return true;
},
[startPlanningRetry],
);
startPlanningAutoRetryRef.current = startPlanningAutoRetry;
const handleStartPlanning = useCallback(async (planOverride?: string) => {
const plan = planOverride ?? initialPlan;
if (!plan.trim()) return;
@@ -897,6 +1106,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setConversationHistory([]);
setResponseHistory([]);
setIsReconnecting(false);
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setView({ type: "loading" });
@@ -948,6 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
planningModelProvider,
planningThinkingLevel,
projectId,
resetPlanningAutoRetryBudget,
]);
/*
@@ -1033,10 +1244,14 @@ 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;
}
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: session.error || t("planning.sessionFailed2", "Session failed"),
errorMessage,
});
return;
}
@@ -1083,6 +1298,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
: null;
setView({ type: "initial" });
} else if (session.status === "awaiting_input" && session.currentQuestion) {
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion));
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
@@ -1102,6 +1318,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
connectToPlanningStream(sessionId);
} else if (session.status === "complete" && session.result) {
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
const summary = normalizePlanningSummary(JSON.parse(session.result));
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
@@ -1122,7 +1339,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
});
}
},
[connectToPlanningStream, projectId],
[connectToPlanningStream, projectId, resetPlanningAutoRetryBudget],
);
// Resume the externally-requested session when the modal first opens.
@@ -1682,6 +1899,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
},
];
});
resetPlanningAutoRetryBudget();
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
@@ -1694,7 +1912,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "question", session });
}
},
[projectId, sessionTabId, view]
[projectId, resetPlanningAutoRetryBudget, sessionTabId, view]
);
const handleRefineFurther = useCallback(async () => {
@@ -1711,6 +1929,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsRefiningSummary(true);
setError(null);
setIsRetrying(false);
resetPlanningAutoRetryBudget();
setStreamingOutput("");
setView({ type: "loading" });
@@ -1730,7 +1949,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(message);
setView({ type: "summary", session, summary: editedSummary ?? summary });
}
}, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]);
}, [connectToPlanningStream, editedSummary, projectId, resetPlanningAutoRetryBudget, sessionTabId, view]);
const handleStopGeneration = useCallback(async () => {
const sessionId = currentSessionIdRef.current;
@@ -1748,6 +1967,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setIsAutoRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setView({
@@ -1763,97 +1983,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return;
}
const retryTarget = view.session;
setError(null);
setIsRetrying(true);
setStreamingOutput("");
setView({ type: "loading" });
connectToPlanningStream(retryTarget.sessionId);
try {
currentSessionIdRef.current = retryTarget.sessionId;
setLockSessionId(retryTarget.sessionId);
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
} catch (err) {
let retryError: unknown = err;
const retryErrorMessage = getErrorMessage(err) || "";
if (retryErrorMessage.includes("not in an error state")) {
try {
const session = await fetchAiSession(retryTarget.sessionId);
if (!session) {
throw new Error("Failed to refresh planning session.");
}
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
if (session.status === "generating") {
setStreamingOutput(session.thinkingOutput ?? "");
setView({ type: "loading" });
} else if (session.status === "awaiting_input") {
if (!session.currentQuestion) {
throw new Error("Planning session is awaiting input but has no current question.");
}
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId: session.id, currentQuestion: question, summary: null },
});
if (session.thinkingOutput) {
const trimmed = session.thinkingOutput.trim();
if (trimmed) {
setConversationHistory((prev) => {
const lastEntry = prev[prev.length - 1];
if (lastEntry?.thinkingOutput === trimmed) return prev;
return [...prev, { thinkingOutput: trimmed }];
});
}
}
if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(session.id);
}
} else if (session.status === "complete") {
if (!session.result) {
throw new Error("Planning session is complete but has no result.");
}
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId: session.id, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
} else if (session.status === "error") {
setView({
type: "error",
session: { sessionId: session.id, currentQuestion: null, summary: null },
errorMessage: session.error || t("planning.retryFailed", "Retry failed. Please try again."),
});
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError) {
retryError = sessionRefreshError;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
session: retryTarget,
errorMessage: getErrorMessage(retryError) || t("planning.retryFailed", "Retry failed. Please try again."),
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToPlanningStream, projectId, sessionTabId, view]);
resetPlanningAutoRetryBudget();
await startPlanningRetry(view.session, { auto: false });
}, [resetPlanningAutoRetryBudget, startPlanningRetry, view]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
@@ -2370,7 +2502,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{view.type === "loading" && (
<div className="planning-loading">
<Loader2 size={40} className="spin icon-todo" />
<p>{streamingOutput ? t("planning.aiThinking", "AI is thinking...") : t("planning.generatingQuestion", "Generating next question...")}</p>
<p>
{isAutoRetrying && autoRetryAttempt > 0
? t("planning.autoRetrying", "Retrying… (attempt {{attempt}} of {{max}})", {
attempt: autoRetryAttempt,
max: MAX_PLANNING_AUTO_RETRIES,
})
: streamingOutput
? t("planning.aiThinking", "AI is thinking...")
: t("planning.generatingQuestion", "Generating next question...")}
</p>
{generationStartTime && (
<div className="planning-elapsed">{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</div>
)}

View File

@@ -942,18 +942,32 @@ describe("PlanningModeModal", () => {
expect(streamHandlers).toBeDefined();
});
it("shows error message when planning fails", async () => {
// Override the default mock to simulate an error
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onError?.("Rate limit exceeded");
}, 10);
it("auto-retries a persisted stream error three times before showing the permanent error", async () => {
const streamHandlers: any[] = [];
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers.push(handlers);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValue({
id: "session-123",
type: "planning",
status: "error",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Rate limit exceeded",
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
render(
<PlanningModeModal
@@ -962,35 +976,69 @@ describe("PlanningModeModal", () => {
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
/>,
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => expect(streamHandlers).toHaveLength(1));
await act(async () => {
streamHandlers[0].onError?.("Rate limit exceeded");
});
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1));
expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined();
await act(async () => {
streamHandlers[1].onError?.("Rate limit exceeded");
});
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(2));
expect(screen.getByText("Retrying… (attempt 2 of 3)")).toBeDefined();
await act(async () => {
streamHandlers[2].onError?.("Rate limit exceeded");
});
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3));
expect(screen.getByText("Retrying… (attempt 3 of 3)")).toBeDefined();
await act(async () => {
streamHandlers[3].onError?.("Rate limit exceeded");
});
await waitFor(() => {
expect(screen.getByText("Rate limit exceeded")).toBeDefined();
});
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3);
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
});
it("retries from error state and reconnects stream", async () => {
let streamAttempt = 0;
it("manual retry still starts a fresh retry after the auto-retry budget is exhausted", async () => {
const streamHandlers: any[] = [];
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Temporary failure"), 10);
} else {
setTimeout(() => handlers.onQuestion?.(mockQuestion), 10);
}
streamHandlers.push(handlers);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValue({
id: "session-123",
type: "planning",
status: "error",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Temporary failure",
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
render(
<PlanningModeModal
@@ -1007,19 +1055,260 @@ describe("PlanningModeModal", () => {
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => expect(streamHandlers).toHaveLength(1));
for (let index = 0; index < 4; index += 1) {
await act(async () => {
streamHandlers[index].onError?.("Temporary failure");
});
}
await waitFor(() => {
expect(screen.getByText("Temporary failure")).toBeDefined();
});
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3);
mockFetchAiSession.mockResolvedValue({
id: "session-123",
type: "planning",
status: "generating",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(4);
});
await act(async () => {
streamHandlers[4].onQuestion?.(mockQuestion);
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
it("resets the auto-retry budget after successful question progress", async () => {
const streamHandlers: any[] = [];
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers.push(handlers);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValue({
id: "session-123",
type: "planning",
status: "error",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Temporary failure",
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => expect(streamHandlers).toHaveLength(1));
await act(async () => {
streamHandlers[0].onError?.("Temporary failure");
});
await waitFor(() => expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined());
await act(async () => {
streamHandlers[1].onQuestion?.(mockQuestion);
});
await waitFor(() => expect(screen.getByText("What is the scope?")).toBeDefined());
await act(async () => {
streamHandlers[1].onError?.("Temporary failure");
});
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(2));
expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined();
expect(screen.queryByText("Temporary failure")).toBeNull();
});
it("single-flights overlapping SSE error and stuck-poll retry signals", async () => {
const streamHandlers: any[] = [];
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>;
});
let resolveRetry!: (value: { success: boolean; sessionId: string }) => void;
const retryPromise = new Promise<{ success: boolean; sessionId: string }>((resolve) => {
resolveRetry = resolve;
});
mockRetryPlanningSession.mockReturnValue(retryPromise);
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers.push(handlers);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValue({
id: "session-123",
type: "planning",
status: "error",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Temporary failure",
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
try {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => expect(streamHandlers).toHaveLength(1));
await waitFor(() => expect(pollTick).toBeDefined());
await act(async () => {
void streamHandlers[0].onError?.("Temporary failure");
await Promise.resolve();
await pollTick?.();
});
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1);
expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined();
await act(async () => {
resolveRetry({ success: true, sessionId: "session-123" });
});
} finally {
setIntervalSpy.mockRestore();
}
});
it("surfaces the permanent error view once the stuck-poll fallback exhausts the auto-retry budget without any SSE onError signal", async () => {
// FN-7946 regression: if the SSE connection never invokes onError (e.g. a
// dropped event) and the 8s watchdog poll is the only signal that discovers
// a terminal session error, the poll path must still surface the permanent
// error view once MAX_PLANNING_AUTO_RETRIES is exhausted — not leave the
// modal stuck on the loading spinner forever.
const streamHandlers: any[] = [];
const pollTicks: Array<() => void | Promise<void>> = [];
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((callback: TimerHandler, timeout?: number) => {
if (timeout === 8000) {
pollTicks.push(callback as () => void | Promise<void>);
}
return 1 as unknown as ReturnType<typeof setInterval>;
});
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers.push(handlers);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValue({
id: "session-123",
type: "planning",
status: "error",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Watchdog aborted a stalled turn",
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
try {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => expect(streamHandlers).toHaveLength(1));
// Drive every retry attempt purely through the watchdog poll — the SSE
// handlers never call onError, simulating a missed/dropped SSE event.
// The interval is registered once while the view stays "loading" across
// retries (lockSessionId/session id do not change), so the same captured
// tick callback is re-invoked on each simulated 8s beat, exactly as the
// real setInterval would re-invoke it.
await waitFor(() => expect(pollTicks.length).toBeGreaterThan(0));
for (let index = 0; index < 4; index += 1) {
await act(async () => {
await pollTicks[0]?.();
});
}
await waitFor(() => {
expect(screen.getByText("Watchdog aborted a stalled turn")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3);
} finally {
setIntervalSpy.mockRestore();
}
});
it("auto-recovers from a stream error when server session is still generating", async () => {
@@ -1966,7 +2255,7 @@ describe("PlanningModeModal", () => {
expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined();
});
it("shows retry panel when resuming an errored session and retries the same session", async () => {
it("auto-retries when resuming an errored session", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-error-1",
type: "planning",
@@ -1995,19 +2284,15 @@ describe("PlanningModeModal", () => {
/>,
);
await waitFor(() => {
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));
});
expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
});
it("shows retry panel when selecting an errored session from the sidebar", async () => {
it("auto-retries when selecting an errored session from the sidebar", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
{
id: "session-sidebar-error",
@@ -2055,15 +2340,11 @@ describe("PlanningModeModal", () => {
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));
});
expect(screen.getByText("Retrying… (attempt 1 of 3)")).toBeDefined();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull();
});
it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {