FN-6979: recover mission interview stream errors
Recover mission interviews from transient AI stream interruptions before surfacing permanent errors. - Refetch mission interview session state when the SSE stream reports an error. - Resume generating, awaiting-input, or completed sessions from persisted state instead of stranding users on a Stream error panel. - Add regression coverage for recoverable and terminal mission interview stream failures. - Add the FN-6979 patch changeset without reintroducing stale FN-6941/FN-6960 release artifacts. Files changed: .changeset/fn-6979-mission-stream-recovery.md | 7 + .../app/components/MissionInterviewModal.tsx | 133 ++++++++++-- .../__tests__/MissionInterviewModal.test.tsx | 235 ++++++++++++++++++--- 3 files changed, 328 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-6979 Fusion-Task-Lineage: a98477c8-f2f4-4d71-bbc9-b24de539e6a2
This commit is contained in:
7
.changeset/fn-6979-mission-stream-recovery.md
Normal file
7
.changeset/fn-6979-mission-stream-recovery.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Recover mission AI planning from transient stream interruptions.
|
||||
category: fix
|
||||
dev: MissionInterviewModal refetches active session state before showing permanent stream errors.
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type MissionPlanFeature,
|
||||
type MissionWithHierarchy,
|
||||
type ModelInfo,
|
||||
type AiSessionDetail,
|
||||
} from "../api";
|
||||
import {
|
||||
saveMissionGoal,
|
||||
@@ -133,6 +134,7 @@ export function MissionInterviewModal({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const streamErrorRecoverySeqRef = useRef(0);
|
||||
const trackedLockSessionRef = useRef<string | null>(null);
|
||||
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
const sessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
@@ -276,24 +278,121 @@ export function MissionInterviewModal({
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || t("missions.interviewErrorDefault", "Session failed while contacting the AI.");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setView({ type: "error", sessionId, errorMessage });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "error",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId, status: "error" });
|
||||
if (currentSessionIdRef.current && currentSessionIdRef.current !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recoverySeq = streamErrorRecoverySeqRef.current + 1;
|
||||
streamErrorRecoverySeqRef.current = recoverySeq;
|
||||
|
||||
/*
|
||||
FNXC:MissionInterview 2026-06-24-21:43:
|
||||
Mission interview SSE errors can be transient while the server-side AI session remains recoverable.
|
||||
Refetch persisted session state before showing the permanent Retry/Dismiss panel so issue #1745 cannot strand users on a literal Stream error.
|
||||
*/
|
||||
setIsReconnecting(true);
|
||||
(async () => {
|
||||
let terminalErrorMessage = errorMessage;
|
||||
|
||||
const isCurrentRecovery = () =>
|
||||
streamErrorRecoverySeqRef.current === recoverySeq &&
|
||||
(!currentSessionIdRef.current || currentSessionIdRef.current === sessionId);
|
||||
|
||||
const restoreHistoryFromSession = (session: AiSessionDetail) => {
|
||||
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)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await fetchAiSession(sessionId);
|
||||
if (!isCurrentRecovery()) return;
|
||||
|
||||
if (session?.type === "mission_interview") {
|
||||
restoreHistoryFromSession(session);
|
||||
currentSessionIdRef.current = session.id;
|
||||
setLockSessionId(session.id);
|
||||
setHasProgress(true);
|
||||
|
||||
if (session.status === "generating") {
|
||||
if (session.thinkingOutput) {
|
||||
setStreamingOutput(session.thinkingOutput);
|
||||
}
|
||||
connectToMissionInterviewStream(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === "awaiting_input") {
|
||||
if (!session.currentQuestion) {
|
||||
throw new Error("Interview session is awaiting input but has no current question.");
|
||||
}
|
||||
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "question", sessionId: session.id, question });
|
||||
connectToMissionInterviewStream(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === "complete") {
|
||||
if (!session.result) {
|
||||
throw new Error("Interview session is complete but has no result.");
|
||||
}
|
||||
const summary = JSON.parse(session.result) as MissionPlanSummary;
|
||||
clearMissionGoal(projectId);
|
||||
setEditedSummary(summary);
|
||||
setView({ type: "summary", sessionId: session.id, summary });
|
||||
setStreamingOutput("");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
broadcastUpdate({
|
||||
sessionId: session.id,
|
||||
status: "complete",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId: session.id, status: "complete" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === "error") {
|
||||
terminalErrorMessage = session.error ?? t("missions.sessionEncounteredError", "The session encountered an error.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrentRecovery()) return;
|
||||
}
|
||||
|
||||
if (!isCurrentRecovery()) return;
|
||||
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setView({ type: "error", sessionId, errorMessage: terminalErrorMessage });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "error",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId, status: "error" });
|
||||
})();
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
|
||||
@@ -51,6 +51,57 @@ const SAMPLE_QUESTION = {
|
||||
],
|
||||
};
|
||||
|
||||
const SECOND_QUESTION = {
|
||||
id: "platform",
|
||||
type: "text" as const,
|
||||
question: "Which platforms should this mission cover?",
|
||||
description: "List the product surfaces that need support.",
|
||||
};
|
||||
|
||||
const SAMPLE_SUMMARY = {
|
||||
missionTitle: "Resilient mission planning",
|
||||
missionDescription: "Recover mission AI planning after transient stream interruptions.",
|
||||
milestones: [
|
||||
{
|
||||
title: "Recovery milestone",
|
||||
description: "Keep the interview usable after reconnecting.",
|
||||
slices: [
|
||||
{
|
||||
title: "Stream recovery",
|
||||
description: "Reconnect recoverable mission interviews.",
|
||||
features: [
|
||||
{
|
||||
title: "Continue interview",
|
||||
description: "The modal resumes from the next streamed state.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function buildMissionSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MissionInterviewModal", () => {
|
||||
let streamHandlers: any;
|
||||
|
||||
@@ -188,7 +239,54 @@ describe("MissionInterviewModal", () => {
|
||||
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error panel with retry action when stream fails", async () => {
|
||||
it("recovers a generating mission interview after a transient Stream error", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce(buildMissionSession({ status: "generating" }));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
streamHandlers.onError?.("Stream error");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(SECOND_QUESTION);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Which platforms should this mission cover?")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves an awaiting-input question while recovering a transient Stream error", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce(
|
||||
buildMissionSession({
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify(SAMPLE_QUESTION),
|
||||
thinkingOutput: "",
|
||||
}),
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
@@ -201,6 +299,78 @@ describe("MissionInterviewModal", () => {
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
streamHandlers.onError?.("Stream error");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onSummary?.(SAMPLE_SUMMARY);
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue("Resilient mission planning")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a completed mission summary instead of Stream error after recovery finds completion", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce(
|
||||
buildMissionSession({
|
||||
status: "complete",
|
||||
result: JSON.stringify(SAMPLE_SUMMARY),
|
||||
thinkingOutput: "",
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
streamHandlers.onError?.("Stream error");
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue("Resilient mission planning")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error panel with retry action when stream recovery cannot refresh the session", async () => {
|
||||
mockFetchAiSession.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
streamHandlers.onError?.("Temporary outage");
|
||||
});
|
||||
|
||||
@@ -208,6 +378,34 @@ describe("MissionInterviewModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows persisted mission interview errors after stream recovery refreshes the session", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce(
|
||||
buildMissionSession({
|
||||
status: "error",
|
||||
error: "The mission interview failed permanently.",
|
||||
thinkingOutput: "",
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
streamHandlers.onError?.("Stream error");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("The mission interview failed permanently.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries interview session from error view", async () => {
|
||||
let attempt = 0;
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
@@ -246,7 +444,7 @@ describe("MissionInterviewModal", () => {
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers retry from connection-loss when interview session is still generating", async () => {
|
||||
it("recovers connection-loss directly when interview session is still generating", async () => {
|
||||
let attempt = 0;
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
@@ -260,26 +458,7 @@ describe("MissionInterviewModal", () => {
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
mockFetchAiSession.mockResolvedValueOnce(buildMissionSession({ status: "generating" }));
|
||||
|
||||
renderModal();
|
||||
|
||||
@@ -289,19 +468,15 @@ describe("MissionInterviewModal", () => {
|
||||
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(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Continuing...")).toBeInTheDocument();
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByText("Connection lost")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
expect(mockRetryMissionInterviewSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows comment textarea and submits _comment for non-text questions", async () => {
|
||||
|
||||
Reference in New Issue
Block a user