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.
|
||||
|
||||
<!-- 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). -->
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user