feat(FN-1152): add retry flows for failed AI planning sessions

- Keep errored AI sessions retryable in the session store and add backend retry handlers for planning, subtask breakdown, and mission interview flows
- Add retry API routes and dashboard API client helpers for planning, subtask, and mission interview session retries
- Update PlanningModeModal, SubtaskBreakdownModal, MissionInterviewModal, and background session handling to show error states with retry/cancel UX
- Expand unit and integration tests across store, services, routes, and modal components to cover retry success and failure paths
This commit is contained in:
gsxdsm
2026-04-08 15:10:46 -07:00
parent 5f53949e2a
commit e80fb448d4
20 changed files with 1368 additions and 234 deletions

View File

@@ -1253,6 +1253,19 @@ export function respondToPlanning(
});
}
/** Retry a failed planning session turn */
export function retryPlanningSession(
sessionId: string,
projectId?: string,
): Promise<{ success: boolean; sessionId: string }> {
return api<{ success: boolean; sessionId: string }>(
withProjectId(`/planning/${encodeURIComponent(sessionId)}/retry`, projectId),
{
method: "POST",
},
);
}
/** Cancel an active planning session */
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId("/planning/cancel", projectId), {
@@ -1803,6 +1816,18 @@ export function startSubtaskBreakdown(description: string, projectId?: string):
});
}
export function retrySubtaskSession(
sessionId: string,
projectId?: string,
): Promise<{ success: boolean; sessionId: string }> {
return api<{ success: boolean; sessionId: string }>(
withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/retry`, projectId),
{
method: "POST",
},
);
}
export function getSubtaskStreamUrl(sessionId: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/stream`, projectId));
}
@@ -3235,6 +3260,19 @@ export function respondToMissionInterview(
});
}
/** Retry a failed mission interview turn */
export function retryMissionInterviewSession(
sessionId: string,
projectId?: string,
): Promise<{ success: boolean; sessionId: string }> {
return api<{ success: boolean; sessionId: string }>(
withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/retry`, projectId),
{
method: "POST",
},
);
}
/** Cancel an active mission interview session */
export function cancelMissionInterview(sessionId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId("/missions/interview/cancel", projectId), {

View File

@@ -4,6 +4,7 @@ import { MissionInterviewModal } from "./MissionInterviewModal";
const mockStartMissionInterview = vi.fn();
const mockRespondToMissionInterview = vi.fn();
const mockRetryMissionInterviewSession = vi.fn();
const mockCancelMissionInterview = vi.fn();
const mockCreateMissionFromInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn();
@@ -13,6 +14,7 @@ const mockParseConversationHistory = vi.fn();
vi.mock("../api", () => ({
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
retryMissionInterviewSession: (...args: any[]) => mockRetryMissionInterviewSession(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
@@ -45,6 +47,7 @@ describe("MissionInterviewModal", () => {
streamHandlers = undefined;
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
mockFetchAiSession.mockResolvedValue(null);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
@@ -135,4 +138,62 @@ describe("MissionInterviewModal", () => {
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
});
it("shows error panel with retry action when stream fails", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onError?.("Temporary outage");
});
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
it("retries interview session from error view", async () => {
let attempt = 0;
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
attempt += 1;
if (attempt === 1) {
setTimeout(() => handlers.onError?.("Try again"), 10);
} else {
setTimeout(() => handlers.onQuestion?.(SAMPLE_QUESTION), 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText("Try again")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
});
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
});
});

View File

@@ -3,6 +3,7 @@ import type { PlanningQuestion } from "@fusion/core";
import {
startMissionInterview,
respondToMissionInterview,
retryMissionInterviewSession,
cancelMissionInterview,
createMissionFromInterview,
connectMissionInterviewStream,
@@ -36,6 +37,7 @@ import {
Plus,
Trash2,
Minimize2,
RefreshCw,
} from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
@@ -56,7 +58,8 @@ type ViewState =
| { type: "initial" }
| { type: "loading" }
| { type: "question"; sessionId: string; question: PlanningQuestion }
| { type: "summary"; sessionId: string; summary: MissionPlanSummary };
| { type: "summary"; sessionId: string; summary: MissionPlanSummary }
| { type: "error"; sessionId: string; errorMessage: string };
const EXAMPLE_MISSIONS = [
"Build a real-time collaborative document editor",
@@ -84,11 +87,61 @@ export function MissionInterviewModal({
const [streamingOutput, setStreamingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const connectToMissionInterviewStream = useCallback(
(sessionId: string) => {
streamConnectionRef.current?.close();
const connection = connectMissionInterviewStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
setIsRetrying(false);
clearMissionGoal(projectId);
setView({ type: "question", sessionId, question });
setStreamingOutput("");
setHasProgress(true);
},
onSummary: (summary) => {
setIsReconnecting(false);
setIsRetrying(false);
clearMissionGoal(projectId);
setView({ type: "summary", sessionId, summary });
setEditedSummary(summary);
setStreamingOutput("");
setHasProgress(true);
},
onError: (message) => {
const errorMessage = message || "Session failed while contacting the AI.";
setIsReconnecting(false);
setIsRetrying(false);
setError(null);
setView({ type: "error", sessionId, errorMessage });
setStreamingOutput("");
setHasProgress(true);
currentSessionIdRef.current = sessionId;
},
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
},
[projectId],
);
const handleStartInterview = useCallback(
async (goalOverride?: string) => {
const goal = goalOverride ?? missionGoal;
@@ -106,42 +159,7 @@ export function MissionInterviewModal({
currentSessionIdRef.current = sessionId;
clearMissionGoal(projectId);
const connection = connectMissionInterviewStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearMissionGoal(projectId);
setView({ type: "question", sessionId, question });
setStreamingOutput("");
setHasProgress(true);
},
onSummary: (summary) => {
setIsReconnecting(false);
clearMissionGoal(projectId);
setView({ type: "summary", sessionId, summary });
setEditedSummary(summary);
setStreamingOutput("");
setHasProgress(true);
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
connectToMissionInterviewStream(sessionId);
setResponseHistory([]);
} catch (err: any) {
setIsReconnecting(false);
@@ -150,7 +168,7 @@ export function MissionInterviewModal({
currentSessionIdRef.current = null;
}
},
[missionGoal, projectId]
[connectToMissionInterviewStream, missionGoal, projectId]
);
// Focus textarea when opening
@@ -182,6 +200,7 @@ export function MissionInterviewModal({
if (!isOpen) {
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
}
}, [isOpen]);
@@ -232,43 +251,16 @@ export function MissionInterviewModal({
setStreamingOutput(session.thinkingOutput);
}
setView({ type: "loading" });
const connection = connectMissionInterviewStream(session.id, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearMissionGoal(projectId);
setView({ type: "question", sessionId: session.id, question });
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearMissionGoal(projectId);
setView({ type: "summary", sessionId: session.id, summary });
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
connectToMissionInterviewStream(session.id);
} else if (session.status === "error") {
setError(session.error ?? "The session encountered an error.");
currentSessionIdRef.current = session.id;
setHasProgress(true);
setError(null);
setView({
type: "error",
sessionId: session.id,
errorMessage: session.error ?? "The session encountered an error.",
});
}
}).catch(() => {
if (!cancelled) setError("Failed to resume session.");
@@ -277,7 +269,7 @@ export function MissionInterviewModal({
return () => {
cancelled = true;
};
}, [isOpen, resumeSessionId, view.type, projectId]);
}, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]);
// Cleanup stream on unmount
useEffect(() => {
@@ -324,7 +316,7 @@ export function MissionInterviewModal({
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (view.type === "question" || view.type === "summary") {
if (view.type === "question" || view.type === "summary" || view.type === "error") {
try {
await cancelMissionInterview(view.sessionId, projectId);
} catch {
@@ -340,6 +332,7 @@ export function MissionInterviewModal({
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
@@ -394,6 +387,35 @@ export function MissionInterviewModal({
[view, projectId]
);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
return;
}
const retrySessionId = view.sessionId;
setError(null);
setIsRetrying(true);
setStreamingOutput("");
setView({ type: "loading" });
connectToMissionInterviewStream(retrySessionId);
try {
currentSessionIdRef.current = retrySessionId;
await retryMissionInterviewSession(retrySessionId, projectId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
sessionId: retrySessionId,
errorMessage: err?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToMissionInterviewStream, projectId, view]);
const handleApprovePlan = useCallback(async () => {
if (view.type !== "summary") return;
@@ -415,6 +437,7 @@ export function MissionInterviewModal({
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
@@ -433,7 +456,7 @@ export function MissionInterviewModal({
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary";
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
if (!isOpen) return null;
@@ -548,6 +571,42 @@ export function MissionInterviewModal({
</div>
)}
{view.type === "error" && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} />
<div className="conversation-separator" />
</>
)}
<div
className="ai-error-panel"
role="alert"
style={{
border: "1px solid var(--color-error, #dc2626)",
borderRadius: "10px",
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
padding: "14px",
display: "grid",
gap: "10px",
}}
>
<div className="ai-error-icon" style={{ fontSize: "20px" }}></div>
<div className="ai-error-message">{view.errorMessage}</div>
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Cancel</button>
</div>
</div>
</div>
</div>
)}
{view.type === "question" && (
<InterviewQuestionForm
question={view.question}

View File

@@ -9,6 +9,7 @@ const mockStartPlanning = vi.fn();
const mockStartPlanningStreaming = vi.fn();
const mockConnectPlanningStream = vi.fn();
const mockRespondToPlanning = vi.fn();
const mockRetryPlanningSession = vi.fn();
const mockCancelPlanning = vi.fn();
const mockCreateTaskFromPlanning = vi.fn();
const mockStartPlanningBreakdown = vi.fn();
@@ -32,6 +33,7 @@ vi.mock("../api", () => ({
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
@@ -134,6 +136,7 @@ describe("PlanningModeModal", () => {
// Default mock for streaming
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
mockFetchAiSession.mockResolvedValue(null);
mockParseConversationHistory.mockImplementation((raw: string) => {
@@ -428,6 +431,53 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(screen.getByText("Rate limit exceeded")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
});
it("retries from error state and reconnects stream", async () => {
let streamAttempt = 0;
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);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
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(screen.getByText("Temporary failure")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined);
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
});
@@ -482,6 +532,40 @@ describe("PlanningModeModal", () => {
expect(screen.getByText("Deliverable B")).toBeDefined();
});
it("shows retry panel when resuming an errored session", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-error-1",
type: "planning",
status: "error",
title: "Errored planning",
inputPayload: JSON.stringify({ initialPlan: "Recover planning" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Session interrupted",
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-error-1"
/>,
);
await waitFor(() => {
expect(screen.getByText("Session interrupted")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
});
it("creates a task from a resumed complete session", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-task",

View File

@@ -3,6 +3,7 @@ import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
import {
startPlanningStreaming,
respondToPlanning,
retryPlanningSession,
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
@@ -20,7 +21,7 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2 } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
@@ -44,6 +45,7 @@ type ViewState =
| { type: "initial" }
| { type: "question"; session: PlanningSession }
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
| { type: "error"; session: PlanningSession; errorMessage: string }
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" }
| { type: "creating" };
@@ -90,6 +92,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [streamingOutput, setStreamingOutput] = useState<string>("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
@@ -140,6 +143,68 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
);
}, []);
const connectToPlanningStream = useCallback(
(sessionId: string) => {
streamConnectionRef.current?.close();
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
setIsRetrying(false);
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
setIsRetrying(false);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
const errorMessage = message || "Session failed while contacting the AI.";
setIsReconnecting(false);
setIsRetrying(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("");
currentSessionIdRef.current = sessionId;
},
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
},
[projectId],
);
const handleStartPlanning = useCallback(async (planOverride?: string) => {
const plan = planOverride ?? initialPlan;
if (!plan.trim()) return;
@@ -161,48 +226,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
// Connect to SSE stream
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
connectToPlanningStream(sessionId);
setResponseHistory([]);
} catch (err: any) {
setIsReconnecting(false);
@@ -210,7 +234,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "initial" });
currentSessionIdRef.current = null;
}
}, [initialPlan, planningModelId, planningModelProvider, projectId]);
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
// Focus textarea when opening
useEffect(() => {
@@ -279,52 +303,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
// Connect to live SSE stream to pick up new events
const connection = connectPlanningStream(resumeSessionId, projectId, {
onThinking: (data) => setStreamingOutput((prev) => prev + data),
onQuestion: (question) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
connectToPlanningStream(resumeSessionId);
} else if (session.status === "error") {
setError(session.error || "Session failed");
setView({ type: "initial" });
setError(null);
setView({
type: "error",
session: { sessionId: resumeSessionId, currentQuestion: null, summary: null },
errorMessage: session.error || "Session failed",
});
}
} catch {
setError("Failed to resume session");
}
})();
return () => { cancelled = true; };
}, [isOpen, resumeSessionId, view.type, projectId]);
}, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]);
// Reset hasAutoStarted when modal closes
useEffect(() => {
if (!isOpen) {
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
}
}, [isOpen]);
@@ -374,6 +374,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
@@ -437,6 +438,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
[projectId, view]
);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
return;
}
const retryTarget = view.session;
setError(null);
setIsRetrying(true);
setStreamingOutput("");
setView({ type: "loading" });
connectToPlanningStream(retryTarget.sessionId);
try {
currentSessionIdRef.current = retryTarget.sessionId;
await retryPlanningSession(retryTarget.sessionId, projectId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
session: retryTarget,
errorMessage: err?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToPlanningStream, projectId, view]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
@@ -519,7 +550,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary";
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
if (!isOpen) return null;
@@ -701,6 +732,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
)}
{view.type === "error" && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} />
<div className="conversation-separator" />
</>
)}
<div
className="ai-error-panel"
role="alert"
style={{
border: "1px solid var(--color-error, #dc2626)",
borderRadius: "10px",
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
padding: "14px",
display: "grid",
gap: "10px",
}}
>
<div className="ai-error-icon" style={{ fontSize: "20px" }}></div>
<div className="ai-error-message">{view.errorMessage}</div>
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Dismiss</button>
</div>
</div>
</div>
</div>
)}
{view.type === "creating" && (
<div className="planning-loading">
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />

View File

@@ -3,12 +3,14 @@ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
const mockStartSubtaskBreakdown = vi.fn();
const mockRetrySubtaskSession = vi.fn();
const mockConnectSubtaskStream = vi.fn();
const mockCreateTasksFromBreakdown = vi.fn();
const mockCancelSubtaskBreakdown = vi.fn();
vi.mock("../api", () => ({
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
retrySubtaskSession: (...args: any[]) => mockRetrySubtaskSession(...args),
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
@@ -40,6 +42,7 @@ describe("SubtaskBreakdownModal", () => {
vi.clearAllMocks();
streamHandlers = undefined;
mockStartSubtaskBreakdown.mockResolvedValue({ sessionId: "session-123" });
mockRetrySubtaskSession.mockResolvedValue({ success: true, sessionId: "session-123" });
mockConnectSubtaskStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
@@ -483,6 +486,20 @@ describe("SubtaskBreakdownModal", () => {
expect(await screen.findByText("Something went wrong")).toBeInTheDocument();
});
it("retries after an error and reconnects stream", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onError("Something went wrong");
const retryButton = await screen.findByRole("button", { name: "Retry" });
fireEvent.click(retryButton);
await waitFor(() => {
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined);
});
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
});
it("shows Stream error fallback when receiving empty error", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import {
startSubtaskBreakdown,
retrySubtaskSession,
connectSubtaskStream,
createTasksFromBreakdown,
cancelSubtaskBreakdown,
@@ -15,7 +16,7 @@ import {
getSubtaskDescription,
clearSubtaskDescription,
} from "../hooks/modalPersistence";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2 } from "lucide-react";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
interface SubtaskBreakdownModalProps {
@@ -32,6 +33,7 @@ type ViewState =
| { type: "initial" }
| { type: "generating"; sessionId: string }
| { type: "editing"; sessionId: string }
| { type: "error"; sessionId: string; errorMessage: string }
| { type: "creating"; sessionId: string };
function createEmptySubtask(index: number): SubtaskItem {
@@ -71,6 +73,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const [thinkingOutput, setThinkingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
// Local description: synced from prop, can fall back to localStorage
const [localDescription, setLocalDescription] = useState(initialDescription);
const [error, setError] = useState<string | null>(null);
@@ -85,7 +88,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
const autoStartedRef = useRef(false);
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating"
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
? view.sessionId
: null;
@@ -95,7 +98,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
return hasDependencyCycle(subtasks);
}, [subtasks]);
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing";
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error";
const resetState = useCallback(() => {
// Save to localStorage before cleanup (preserve for re-entry)
@@ -110,6 +113,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
setThinkingOutput("");
setShowThinking(true);
setIsReconnecting(false);
setIsRetrying(false);
setError(null);
setDirty(false);
autoStartedRef.current = false;
@@ -136,6 +140,34 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
onClose();
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
const connectToSubtaskStream = useCallback(
(activeSessionId: string) => {
streamRef.current?.close();
streamRef.current = connectSubtaskStream(activeSessionId, projectId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setIsReconnecting(false);
setIsRetrying(false);
clearSubtaskDescription(projectId);
setSubtasks(items);
setView({ type: "editing", sessionId: activeSessionId });
setDirty(false);
},
onError: (message) => {
const errorMessage = message || "Session failed while contacting the AI.";
setIsReconnecting(false);
setIsRetrying(false);
setError(null);
setView({ type: "error", sessionId: activeSessionId, errorMessage });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
},
[projectId],
);
const beginBreakdown = useCallback(async () => {
if (!localDescription.trim()) return;
setError(null);
@@ -146,30 +178,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
try {
const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId);
setView({ type: "generating", sessionId });
streamRef.current?.close();
streamRef.current = connectSubtaskStream(sessionId, projectId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setIsReconnecting(false);
clearSubtaskDescription(projectId);
setSubtasks(items);
setView({ type: "editing", sessionId });
setDirty(false);
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
connectToSubtaskStream(sessionId);
} catch (err: any) {
setError(err.message || "Failed to start subtask breakdown");
setView({ type: "initial" });
}
}, [localDescription, projectId]);
}, [connectToSubtaskStream, localDescription, projectId]);
useEffect(() => {
if (!isOpen) {
@@ -204,38 +218,25 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
if (session.status === "generating" || session.status === "awaiting_input") {
setThinkingOutput(session.thinkingOutput ?? "");
setView({ type: "generating", sessionId: resumeSessionId });
streamRef.current?.close();
streamRef.current = connectSubtaskStream(resumeSessionId, projectId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setIsReconnecting(false);
clearSubtaskDescription(projectId);
setSubtasks(items);
setView({ type: "editing", sessionId: resumeSessionId });
setDirty(false);
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
connectToSubtaskStream(resumeSessionId);
} else if (session.status === "complete" && session.result) {
clearSubtaskDescription(projectId);
const items = JSON.parse(session.result) as SubtaskItem[];
setSubtasks(items);
setView({ type: "editing", sessionId: resumeSessionId });
} else if (session.status === "error") {
setError(session.error ?? "Session encountered an error");
setError(null);
setView({
type: "error",
sessionId: resumeSessionId,
errorMessage: session.error ?? "Session encountered an error",
});
}
} catch (err: any) {
setError(err.message || "Failed to resume session");
}
})();
}, [isOpen, resumeSessionId, view.type, projectId]);
}, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]);
useEffect(() => {
return () => {
@@ -375,6 +376,34 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
}
}, [isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]);
const handleRetry = useCallback(async () => {
if (view.type !== "error") {
return;
}
const retrySessionId = view.sessionId;
setError(null);
setIsRetrying(true);
setThinkingOutput("");
setView({ type: "generating", sessionId: retrySessionId });
connectToSubtaskStream(retrySessionId);
try {
await retrySubtaskSession(retrySessionId, projectId);
} catch (err: any) {
streamRef.current?.close();
streamRef.current = null;
setView({
type: "error",
sessionId: retrySessionId,
errorMessage: err?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToSubtaskStream, projectId, view]);
if (!isOpen) return null;
return (
@@ -438,6 +467,41 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</div>
)}
{view.type === "error" && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} defaultShowThinking={true} />
<div className="conversation-separator" />
</>
)}
<div
className="ai-error-panel"
role="alert"
style={{
border: "1px solid var(--color-error, #dc2626)",
borderRadius: "10px",
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
padding: "14px",
display: "grid",
gap: "10px",
}}
>
<div className="ai-error-icon" style={{ fontSize: "20px" }}></div>
<div className="ai-error-message">{view.errorMessage}</div>
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
<button className="btn btn-primary" onClick={() => void handleRetry()} disabled={isRetrying}>
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={() => void handleClose()} disabled={isRetrying}>Cancel</button>
</div>
</div>
</div>
</div>
)}
{(view.type === "editing" || view.type === "creating") && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">

View File

@@ -40,11 +40,12 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
next[idx] = updated;
return next;
}
// New session — only add if active
// New session — include in-progress, complete, and retryable error sessions
if (
updated.status === "generating" ||
updated.status === "awaiting_input" ||
updated.status === "complete"
updated.status === "complete" ||
updated.status === "error"
) {
return [updated, ...prev];
}
@@ -77,7 +78,11 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
// Filter to only active sessions
const active = sessions.filter(
(s) => s.status === "generating" || s.status === "awaiting_input" || s.status === "complete"
(s) =>
s.status === "generating" ||
s.status === "awaiting_input" ||
s.status === "complete" ||
s.status === "error",
);
const planningSessions = active.filter((s) => s.type === "planning");