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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user