fix(FN-2151): recover modal retries after stream disconnects

- Handle retry API "not in an error state" responses by refreshing the server session instead of failing immediately
- Restore the correct view state for generating, awaiting_input, complete, and error sessions during retry recovery
- Reconnect planning/interview/subtask streams only when needed while preserving in-flight output and history
- Add regression tests for connection-loss retry recovery across PlanningModeModal, MissionInterviewModal, and SubtaskBreakdownModal
This commit is contained in:
Fusion
2026-04-19 10:37:08 -07:00
committed by gsxdsm
parent 5eb15f9454
commit a794cc0237
6 changed files with 409 additions and 3 deletions

View File

@@ -234,4 +234,62 @@ describe("MissionInterviewModal", () => {
});
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
});
it("recovers retry from connection-loss when interview session is still generating", async () => {
let attempt = 0;
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
attempt += 1;
if (attempt === 1) {
setTimeout(() => handlers.onError?.("Connection lost"), 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRetryMissionInterviewSession.mockRejectedValueOnce(
new Error("Mission interview session mission-session-1 is not in an error state"),
);
mockFetchAiSession.mockResolvedValueOnce({
id: "mission-session-1",
type: "mission_interview",
status: "generating",
title: "Build a mission planning workflow",
inputPayload: JSON.stringify({ goal: "Build a mission planning workflow" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "Continuing...",
error: null,
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
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("Connection lost")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
});
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
expect(screen.getByText("Continuing...")).toBeInTheDocument();
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
});
});

View File

@@ -620,12 +620,75 @@ export function MissionInterviewModal({
setLockSessionId(retrySessionId);
await retryMissionInterviewSession(retrySessionId, projectId, sessionTabId);
} catch (err: any) {
let retryError = err;
const retryErrorMessage = err?.message || "";
if (retryErrorMessage.includes("not in an error state")) {
try {
const session = await fetchAiSession(retrySessionId);
if (!session) {
throw new Error("Failed to refresh interview session.");
}
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
setHasProgress(true);
if (session.status === "generating") {
setStreamingOutput(session.thinkingOutput ?? "");
setView({ type: "loading" });
if (!streamConnectionRef.current?.isConnected()) {
connectToMissionInterviewStream(session.id);
}
} else if (session.status === "awaiting_input") {
if (!session.currentQuestion) {
throw new Error("Interview session is awaiting input but has no current question.");
}
clearMissionGoal(projectId);
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
setView({ type: "question", sessionId: session.id, question });
if (!streamConnectionRef.current?.isConnected()) {
connectToMissionInterviewStream(session.id);
}
} else if (session.status === "complete") {
if (!session.result) {
throw new Error("Interview session is complete but has no result.");
}
clearMissionGoal(projectId);
const summary = JSON.parse(session.result) as MissionPlanSummary;
setEditedSummary(summary);
setView({ type: "summary", sessionId: session.id, summary });
} else if (session.status === "error") {
setView({
type: "error",
sessionId: session.id,
errorMessage: session.error ?? "Retry failed. Please try again.",
});
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError: any) {
retryError = sessionRefreshError;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
sessionId: retrySessionId,
errorMessage: err?.message || "Retry failed. Please try again.",
errorMessage: retryError?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {

View File

@@ -609,6 +609,131 @@ describe("PlanningModeModal", () => {
});
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
it("recovers retry from connection-loss when server session is still generating", async () => {
let streamAttempt = 0;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Connection lost"), 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRetryPlanningSession.mockRejectedValueOnce(new Error("Planning session session-123 is not in an error state"));
mockFetchAiSession.mockResolvedValueOnce({
id: "session-123",
type: "planning",
status: "generating",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "Still thinking...",
error: null,
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(screen.getByText("Connection lost")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
expect(mockFetchAiSession).toHaveBeenCalledWith("session-123");
});
expect(await screen.findByText("AI is thinking...")).toBeDefined();
expect(screen.getByText("Still thinking...")).toBeDefined();
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
it("recovers retry from connection-loss when server session is awaiting input", async () => {
let streamAttempt = 0;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Connection lost"), 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRetryPlanningSession.mockRejectedValueOnce(new Error("Planning session session-123 is not in an error state"));
mockFetchAiSession.mockResolvedValueOnce({
id: "session-123",
type: "planning",
status: "awaiting_input",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify(mockQuestion),
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,
});
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("Connection lost")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
expect(mockFetchAiSession).toHaveBeenCalledWith("session-123");
});
expect(await screen.findByText("What is the scope?")).toBeDefined();
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
});
describe("Resuming complete sessions", () => {

View File

@@ -610,12 +610,68 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setLockSessionId(retryTarget.sessionId);
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
} catch (err: any) {
let retryError = err;
const retryErrorMessage = err?.message || "";
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 (!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 = 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 || "Retry failed. Please try again.",
});
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError: any) {
retryError = sessionRefreshError;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
session: retryTarget,
errorMessage: err?.message || "Retry failed. Please try again.",
errorMessage: retryError?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {

View File

@@ -7,6 +7,8 @@ const mockRetrySubtaskSession = vi.fn();
const mockConnectSubtaskStream = vi.fn();
const mockCreateTasksFromBreakdown = vi.fn();
const mockCancelSubtaskBreakdown = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
@@ -17,6 +19,8 @@ vi.mock("../api", () => ({
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
@@ -55,6 +59,16 @@ describe("SubtaskBreakdownModal", () => {
});
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "FN-101" }, { id: "FN-102" }] });
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
mockFetchAiSession.mockResolvedValue(null);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
@@ -561,6 +575,54 @@ describe("SubtaskBreakdownModal", () => {
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
});
it("recovers retry from connection-loss when session is still generating", async () => {
let streamAttempt = 0;
mockConnectSubtaskStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Connection lost"), 10);
}
return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) };
});
mockRetrySubtaskSession.mockRejectedValueOnce(new Error("Subtask session session-123 is not in an error state"));
mockFetchAiSession.mockResolvedValueOnce({
id: "session-123",
type: "subtask",
status: "generating",
title: "Build a complex feature",
inputPayload: JSON.stringify({ description: "Build a complex feature" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "Still generating...",
error: null,
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Connection lost")).toBeInTheDocument();
});
const retryButton = await screen.findByRole("button", { name: "Retry" });
fireEvent.click(retryButton);
await waitFor(() => {
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
expect(mockFetchAiSession).toHaveBeenCalledWith("session-123");
});
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
expect(screen.getByText("Still generating...")).toBeInTheDocument();
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
});
it("shows Stream error fallback when receiving empty error", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());

View File

@@ -499,12 +499,54 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
try {
await retrySubtaskSession(retrySessionId, projectId, sessionTabId);
} catch (err: any) {
let retryError = err;
const retryErrorMessage = err?.message || "";
if (retryErrorMessage.includes("not in an error state")) {
try {
const session = await fetchAiSession(retrySessionId);
if (!session) {
throw new Error("Failed to refresh subtask session.");
}
setConversationHistory(parseConversationHistory(session.conversationHistory));
if (session.status === "generating" || session.status === "awaiting_input") {
setThinkingOutput(session.thinkingOutput ?? "");
setView({ type: "generating", sessionId: session.id });
if (!streamRef.current?.isConnected()) {
connectToSubtaskStream(session.id);
}
} else if (session.status === "complete") {
if (!session.result) {
throw new Error("Subtask session is complete but has no result.");
}
clearSubtaskDescription(projectId);
const items = JSON.parse(session.result) as SubtaskItem[];
setSubtasks(items);
setView({ type: "editing", sessionId: session.id });
setDirty(false);
} else if (session.status === "error") {
setView({
type: "error",
sessionId: session.id,
errorMessage: session.error ?? "Retry failed. Please try again.",
});
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError: any) {
retryError = sessionRefreshError;
}
}
streamRef.current?.close();
streamRef.current = null;
setView({
type: "error",
sessionId: retrySessionId,
errorMessage: err?.message || "Retry failed. Please try again.",
errorMessage: retryError?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {