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:
gsxdsm
2026-06-25 00:21:02 -07:00
parent 2fb87c9c7a
commit 7e7b0c6818
3 changed files with 328 additions and 47 deletions

View 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.

View File

@@ -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);

View File

@@ -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 () => {