From 7e7b0c68185133a7bc32239bbc14ee95291584f0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 00:21:02 -0700 Subject: [PATCH] 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 --- .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(-) create mode 100644 .changeset/fn-6979-mission-stream-recovery.md diff --git a/.changeset/fn-6979-mission-stream-recovery.md b/.changeset/fn-6979-mission-stream-recovery.md new file mode 100644 index 0000000000..c4a3652bb0 --- /dev/null +++ b/.changeset/fn-6979-mission-stream-recovery.md @@ -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. diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx index 5bd9248720..056051c70d 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.tsx +++ b/packages/dashboard/app/components/MissionInterviewModal.tsx @@ -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(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); + const streamErrorRecoverySeqRef = useRef(0); const trackedLockSessionRef = useRef(null); const [lockSessionId, setLockSessionId] = useState(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); diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx index 89dd040a7f..265e16c49a 100644 --- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx @@ -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 = {}) { + 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 () => {