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:
7
.changeset/fn-7946-planning-auto-retry.md
Normal file
7
.changeset/fn-7946-planning-auto-retry.md
Normal 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.
|
||||||
@@ -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.
|
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: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). -->
|
<!-- 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). -->
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const PLANNING_SIDEBAR_MIN_WIDTH = 220;
|
|||||||
const PLANNING_SIDEBAR_MAX_WIDTH = 560;
|
const PLANNING_SIDEBAR_MAX_WIDTH = 560;
|
||||||
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
|
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
|
||||||
|
|
||||||
|
const MAX_PLANNING_AUTO_RETRIES = 3;
|
||||||
|
|
||||||
interface PlanningModeModalProps {
|
interface PlanningModeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -320,6 +322,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const [showThinking, setShowThinking] = useState(true);
|
const [showThinking, setShowThinking] = useState(true);
|
||||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
const [isRetrying, setIsRetrying] = useState(false);
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
const [isAutoRetrying, setIsAutoRetrying] = useState(false);
|
||||||
|
const [autoRetryAttempt, setAutoRetryAttempt] = useState(0);
|
||||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
||||||
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
|
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
|
||||||
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false);
|
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false);
|
||||||
@@ -351,6 +355,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||||
const currentSessionIdRef = useRef<string | 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:
|
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.
|
||||||
@@ -370,6 +382,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// yanks the user back into the previous session's question view.
|
// yanks the user back into the previous session's question view.
|
||||||
const dismissedResumeRef = useRef<string | null>(null);
|
const dismissedResumeRef = useRef<string | null>(null);
|
||||||
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? 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 sessionTabId = useMemo(() => getSessionTabId(), []);
|
||||||
const {
|
const {
|
||||||
isLockedByOther,
|
isLockedByOther,
|
||||||
@@ -589,6 +612,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
if (cancelled || !session) return;
|
if (cancelled || !session) return;
|
||||||
if (currentSessionIdRef.current !== sessionId) return;
|
if (currentSessionIdRef.current !== sessionId) return;
|
||||||
if (session.status === "awaiting_input" && session.currentQuestion) {
|
if (session.status === "awaiting_input" && session.currentQuestion) {
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
|
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
|
||||||
setView({
|
setView({
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -596,6 +620,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
});
|
});
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
} else if (session.status === "complete" && session.result) {
|
} else if (session.status === "complete" && session.result) {
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
|
||||||
setView({
|
setView({
|
||||||
type: "summary",
|
type: "summary",
|
||||||
@@ -604,6 +629,46 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
});
|
});
|
||||||
setEditedSummary(summary);
|
setEditedSummary(summary);
|
||||||
setStreamingOutput("");
|
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 {
|
} catch {
|
||||||
// best-effort; keep polling
|
// best-effort; keep polling
|
||||||
@@ -615,7 +680,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [view.type]);
|
}, [broadcastCompleted, broadcastUpdate, initialPlan, lockSessionId, projectId, resetPlanningAutoRetryBudget, sessionTabId, t, view.type]);
|
||||||
|
|
||||||
const resetDetailState = useCallback(() => {
|
const resetDetailState = useCallback(() => {
|
||||||
setInitialPlan("");
|
setInitialPlan("");
|
||||||
@@ -630,6 +695,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
setPlanningModelProvider(undefined);
|
setPlanningModelProvider(undefined);
|
||||||
@@ -639,7 +705,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setCustomQuestionCount("");
|
setCustomQuestionCount("");
|
||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
setLockSessionId(null);
|
setLockSessionId(null);
|
||||||
}, []);
|
}, [resetPlanningAutoRetryBudget]);
|
||||||
|
|
||||||
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
|
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
|
||||||
|
|
||||||
@@ -744,6 +810,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const normalizedQuestion = normalizeQuestionOptions(question);
|
const normalizedQuestion = normalizeQuestionOptions(question);
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
clearPlanningDescription(projectId);
|
clearPlanningDescription(projectId);
|
||||||
@@ -785,6 +852,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const normalizedSummary = normalizePlanningSummary(summary);
|
const normalizedSummary = normalizePlanningSummary(summary);
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
clearPlanningDescription(projectId);
|
clearPlanningDescription(projectId);
|
||||||
@@ -841,7 +909,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsReconnecting(false);
|
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);
|
setIsRetrying(false);
|
||||||
|
setIsAutoRetrying(false);
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -873,6 +949,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
@@ -885,9 +962,141 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
streamConnectionRef.current = connection;
|
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 handleStartPlanning = useCallback(async (planOverride?: string) => {
|
||||||
const plan = planOverride ?? initialPlan;
|
const plan = planOverride ?? initialPlan;
|
||||||
if (!plan.trim()) return;
|
if (!plan.trim()) return;
|
||||||
@@ -897,6 +1106,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setConversationHistory([]);
|
setConversationHistory([]);
|
||||||
setResponseHistory([]);
|
setResponseHistory([]);
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
@@ -948,6 +1158,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
planningModelProvider,
|
planningModelProvider,
|
||||||
planningThinkingLevel,
|
planningThinkingLevel,
|
||||||
projectId,
|
projectId,
|
||||||
|
resetPlanningAutoRetryBudget,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1033,10 +1244,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (session.status === "error") {
|
if (session.status === "error") {
|
||||||
|
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
|
||||||
|
if (await startPlanningAutoRetryRef.current(sessionId, errorMessage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setView({
|
setView({
|
||||||
type: "error",
|
type: "error",
|
||||||
session: { sessionId, currentQuestion: null, summary: null },
|
session: { sessionId, currentQuestion: null, summary: null },
|
||||||
errorMessage: session.error || t("planning.sessionFailed2", "Session failed"),
|
errorMessage,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1083,6 +1298,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
: null;
|
: null;
|
||||||
setView({ type: "initial" });
|
setView({ type: "initial" });
|
||||||
} else if (session.status === "awaiting_input" && session.currentQuestion) {
|
} else if (session.status === "awaiting_input" && session.currentQuestion) {
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
clearPlanningDescription(projectId);
|
clearPlanningDescription(projectId);
|
||||||
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion));
|
const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion));
|
||||||
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
|
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
|
||||||
@@ -1102,6 +1318,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
}
|
}
|
||||||
connectToPlanningStream(sessionId);
|
connectToPlanningStream(sessionId);
|
||||||
} else if (session.status === "complete" && session.result) {
|
} else if (session.status === "complete" && session.result) {
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
clearPlanningDescription(projectId);
|
clearPlanningDescription(projectId);
|
||||||
const summary = normalizePlanningSummary(JSON.parse(session.result));
|
const summary = normalizePlanningSummary(JSON.parse(session.result));
|
||||||
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
|
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.
|
// 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" });
|
setView({ type: "loading" });
|
||||||
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
||||||
|
|
||||||
@@ -1694,7 +1912,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setView({ type: "question", session });
|
setView({ type: "question", session });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, sessionTabId, view]
|
[projectId, resetPlanningAutoRetryBudget, sessionTabId, view]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefineFurther = useCallback(async () => {
|
const handleRefineFurther = useCallback(async () => {
|
||||||
@@ -1711,6 +1929,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setIsRefiningSummary(true);
|
setIsRefiningSummary(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
resetPlanningAutoRetryBudget();
|
||||||
setStreamingOutput("");
|
setStreamingOutput("");
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
|
|
||||||
@@ -1730,7 +1949,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setError(message);
|
setError(message);
|
||||||
setView({ type: "summary", session, summary: editedSummary ?? summary });
|
setView({ type: "summary", session, summary: editedSummary ?? summary });
|
||||||
}
|
}
|
||||||
}, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]);
|
}, [connectToPlanningStream, editedSummary, projectId, resetPlanningAutoRetryBudget, sessionTabId, view]);
|
||||||
|
|
||||||
const handleStopGeneration = useCallback(async () => {
|
const handleStopGeneration = useCallback(async () => {
|
||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
@@ -1748,6 +1967,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
setIsAutoRetrying(false);
|
||||||
setIsRefiningSummary(false);
|
setIsRefiningSummary(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
refineSummaryInFlightRef.current = false;
|
||||||
setView({
|
setView({
|
||||||
@@ -1763,97 +1983,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryTarget = view.session;
|
resetPlanningAutoRetryBudget();
|
||||||
setError(null);
|
await startPlanningRetry(view.session, { auto: false });
|
||||||
setIsRetrying(true);
|
}, [resetPlanningAutoRetryBudget, startPlanningRetry, view]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleCreateTask = useCallback(async () => {
|
const handleCreateTask = useCallback(async () => {
|
||||||
if (view.type !== "summary") return;
|
if (view.type !== "summary") return;
|
||||||
@@ -2370,7 +2502,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
{view.type === "loading" && (
|
{view.type === "loading" && (
|
||||||
<div className="planning-loading">
|
<div className="planning-loading">
|
||||||
<Loader2 size={40} className="spin icon-todo" />
|
<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 && (
|
{generationStartTime && (
|
||||||
<div className="planning-elapsed">{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</div>
|
<div className="planning-elapsed">{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -942,18 +942,32 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(streamHandlers).toBeDefined();
|
expect(streamHandlers).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error message when planning fails", async () => {
|
it("auto-retries a persisted stream error three times before showing the permanent error", async () => {
|
||||||
// Override the default mock to simulate an error
|
const streamHandlers: any[] = [];
|
||||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||||
setTimeout(() => {
|
streamHandlers.push(handlers);
|
||||||
handlers.onError?.("Rate limit exceeded");
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
isConnected: vi.fn().mockReturnValue(true),
|
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(
|
render(
|
||||||
<PlanningModeModal
|
<PlanningModeModal
|
||||||
@@ -962,35 +976,69 @@ describe("PlanningModeModal", () => {
|
|||||||
onTaskCreated={mockOnTaskCreated}
|
onTaskCreated={mockOnTaskCreated}
|
||||||
onTasksCreated={vi.fn()}
|
onTasksCreated={vi.fn()}
|
||||||
tasks={mockTasks}
|
tasks={mockTasks}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Start Planning"));
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Rate limit exceeded")).toBeDefined();
|
expect(screen.getByText("Rate limit exceeded")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3);
|
||||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries from error state and reconnects stream", async () => {
|
it("manual retry still starts a fresh retry after the auto-retry budget is exhausted", async () => {
|
||||||
let streamAttempt = 0;
|
const streamHandlers: any[] = [];
|
||||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||||
streamAttempt += 1;
|
streamHandlers.push(handlers);
|
||||||
if (streamAttempt === 1) {
|
|
||||||
setTimeout(() => handlers.onError?.("Temporary failure"), 10);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => handlers.onQuestion?.(mockQuestion), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
isConnected: vi.fn().mockReturnValue(true),
|
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(
|
render(
|
||||||
<PlanningModeModal
|
<PlanningModeModal
|
||||||
@@ -1007,19 +1055,260 @@ describe("PlanningModeModal", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByText("Start Planning"));
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Temporary failure")).toBeDefined();
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
|
expect(mockRetryPlanningSession).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
streamHandlers[4].onQuestion?.(mockQuestion);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
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 () => {
|
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();
|
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({
|
mockFetchAiSession.mockResolvedValueOnce({
|
||||||
id: "session-error-1",
|
id: "session-error-1",
|
||||||
type: "planning",
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined, expect.any(String));
|
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([
|
mockFetchAiSessions.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: "session-sidebar-error",
|
id: "session-sidebar-error",
|
||||||
@@ -2055,15 +2340,11 @@ describe("PlanningModeModal", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error");
|
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(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 () => {
|
it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user