## Problem Reported: planning gets stuck in a cycle of retrying and regenerating after a response was already supplied. After the user answers a planning question, `submitResponse` pushed the answer to history but left `session.currentQuestion` pointing at the just-answered question for the whole next generation. The planning SSE route's catch-up path re-emits `currentQuestion` to every fresh connection — and each FN-7946 auto-retry (#2073) opens a fresh connection. So after any generation error: 1. Auto-retry connects a fresh stream → the server re-emits the **already-answered** question. 2. The client treats any question event as progress: it **resets the 3-attempt auto-retry budget** and re-shows the answered question. 3. The retry regenerates; if it errors again the cycle repeats with a fresh budget — an unbounded retry/regenerate loop. Re-answering the stale question also 409-collided with the in-flight generation, feeding the same loop. ## Fix Invariant: `currentQuestion` is only set while the session is genuinely awaiting user input. - `submitResponse` clears it the moment an answer is accepted (normal turns and the deepening checkpoint), while preserving the legacy 200 respond contract on generation failure (the modal ignores the body and lets the SSE error drive recovery). - `retrySession` scrubs stale questions persisted by pre-fix builds before regenerating. - `buildSessionFromRow` only restores a question when the persisted row is `awaiting_input`. - `didSubmitSameAnswer` now compares against the last history entry so the duplicate-submit 409 message survives. - Agent onboarding gets the same fix (its SSE route also re-emits `currentQuestion` on connect); retry now asks the next question instead of re-asking the answered one. Surface enumeration: mission and milestone interviews keep questions the same way but their SSE routes never re-emit on connect, and the auto-retry budget machinery is Planning-Mode-only — planning + onboarding were the two affected surfaces. ## Symptom Verification - **Original symptom:** after answering a question, Planning Mode loops between "Retrying…" and regenerating, re-showing the already-answered question, with the auto-retry budget never exhausting. - **Exact reproduction:** answer a question, have the next generation fail (stuck watchdog/provider error), let the client auto-retry open a fresh SSE connection. - **Assertion it is gone:** new regression suite `planning-answered-question-reemit.test.ts` asserts `currentQuestion` is cleared mid-generation, on generation failure, on retry, and on restore from non-`awaiting_input` rows — so the SSE catch-up path has nothing stale to re-emit. All 5 tests fail against pre-fix code and pass with the fix; an onboarding regression test covers the sibling surface. ## Verification - New regression tests: 5/5 fail on pre-fix code, pass with the fix (plus 1 onboarding test). - Existing suites: 137 planning server tests pass (3 failures in `routes-planning.test.ts` fail identically without this change — pre-existing on the branch); all 69 `PlanningModeModal.planning-flow` client tests pass; `tsc --noEmit` clean; `pnpm check:changesets` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Made Planning Mode (and related planning controls) lock-free and multi-tab—no more take-over/active-in-another-tab lock overlays. * **Bug Fixes** * Fixed Planning Mode retry/generation flows where already-answered questions could reappear. * Ensured answered questions clear immediately and aren’t re-emitted during session recovery/SSE catch-up. * Improved session restoration and preserved legacy recovery behavior when generation fails after an answer. * **Tests** * Added regression coverage for the answered-question invariant and updated existing tests to reflect lock-free behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Follow-up: Planning Mode is now multi-tab via DB state (lock-free) Second commit removes all cross-tab coordination from planning — the persisted session row is the single source of truth and multiple tabs can read and interact with the same session: - **Server:** `/planning/*` routes no longer run `checkSessionLock` or parse `tabId`; a stale `tabId` from an older client is ignored instead of 409'd. Subtask/mission interview routes keep their existing lock behavior. - **Client:** `PlanningModeModal` drops `useSessionLock`, the `useAiSessionSync` BroadcastChannel broadcasts, `sessionTabId`/`lockSessionId` state, and the "Take Control" overlay. Tabs stay current via the per-session SSE stream plus the global `ai_session:updated` events `useBackgroundSessions` already consumes; concurrent writes resolve via the server's generation-in-progress guard (409). - **API client:** planning functions lose their `tabId` params. - **Fix uncovered by the refactor:** the 8s stuck-poll now resolves the session id inside each tick — the removed lock state was what previously re-armed the poll after Start Planning resolved the session id. - Also fixes a pre-existing PG-cutover break in `planning-generation-cancellation.test.ts` (`getSession` is async). Verification: 144 client planning tests and 137 server planning tests pass (the 3 remaining `routes-planning.test.ts` failures are pre-existing on the branch and fail identically without these changes); `tsc --noEmit` and eslint clean on changed files; `pnpm check:changesets` passes. Lock-conflict route tests were rewritten to assert lock-free semantics, plus a new modal test proving a session stays fully interactive with no lock acquisition even when another tab is active. --- ## Follow-up 2: the per-tab session lock is gone entirely Third commit extends the multi-tab model from planning to **every** AI interview surface (planning, subtask breakdown, mission interview, milestone/slice interview) and deletes the lock machinery root and branch. **Server** - Deleted the `/ai-sessions/:id/lock`, `/lock/force`, and `/lock/beacon` routes. - Dropped `checkSessionLock` from every planning/subtask/mission/milestone route (both copies — `routes.ts` and `mission-routes.ts`). A `tabId` from an older client is ignored, never 409'd; all `tabId` body parsing is gone. - Dropped `acquireLock` / `releaseLock` / `forceAcquireLock` / `getLockHolder` / `releaseStaleLocks` from `AiSessionStore`, plus the `@fusion/core` async helpers (`acquireAiSessionLock` et al) and core's re-exports. - Removed `lockedByTab`/`lockedAt` from `AiSessionRow`/`AiSessionSummary`, the upsert SQL, and all four session producers. **Client** - Deleted `useSessionLock` and the now-orphaned `getSessionTabId` util. - Removed the Take Control overlay, the "active in another tab" banners, and `BackgroundTasksIndicator`'s active-elsewhere gate (the confirm prompt and lock badge — sessions now just open). - Reduced `useAiSessionSync` to what its own comments already called it — a low-latency *status* supplement to SSE: no `activeTabMap`, `broadcastLock/Unlock/Heartbeat`, `owningTabId`, `tab:*` messages, or stale-heartbeat sweep. - Dropped `tabId` from every session API client function; removed the lock CSS. **Deliberately kept: the two DB columns.** `ai_sessions.locked_by_tab` / `locked_at` remain as dead, always-NULL columns with a deprecation note. Dropping them is an irreversible migration, and released binaries still name those columns explicitly in their upsert — an older install pointed at the same database would fail every session write. They can be dropped once no such binary can reach it. No code reads or writes them. **Verification**: 397 client tests and 137 server planning tests pass (the same 3 `routes-planning.test.ts` failures are pre-existing — verified identical on a clean stash); `tsc --noEmit` clean for `@fusion/core` and `@fusion/dashboard`; eslint clean on all changed files; the 30 PG `schema-applier` tests pass (they exercise the retained columns); `pnpm check:changesets` passes. The lock-conflict route tests and both modal lock tests were rewritten to assert the inverse: routes and modals stay fully interactive while another tab "holds" a lock, and the lock API is never called. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
963 lines
33 KiB
TypeScript
963 lines
33 KiB
TypeScript
import type React from "react";
|
|
import { readFileSync } from "node:fs";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { MissionInterviewModal } from "../MissionInterviewModal";
|
|
|
|
const missionInterviewCss = readFileSync("app/components/MissionInterviewModal.css", "utf8");
|
|
|
|
const mockStartMissionInterview = vi.fn();
|
|
const mockRespondToMissionInterview = vi.fn();
|
|
const mockRetryMissionInterviewSession = vi.fn();
|
|
const mockCancelMissionInterview = vi.fn();
|
|
const mockCreateMissionFromInterview = vi.fn();
|
|
const mockConnectMissionInterviewStream = vi.fn();
|
|
const mockFetchAiSession = vi.fn();
|
|
const mockParseConversationHistory = vi.fn();
|
|
const mockAcquireSessionLock = vi.fn();
|
|
const mockReleaseSessionLock = vi.fn();
|
|
const mockForceAcquireSessionLock = vi.fn();
|
|
const mockFetchModels = vi.fn();
|
|
|
|
vi.mock("../../api", () => ({
|
|
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
|
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
|
|
retryMissionInterviewSession: (...args: any[]) => mockRetryMissionInterviewSession(...args),
|
|
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
|
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
|
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...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),
|
|
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
|
}));
|
|
|
|
const mockGetMissionGoal = vi.fn(() => "");
|
|
const mockSaveMissionGoal = vi.fn();
|
|
|
|
vi.mock("../../hooks/modalPersistence", () => ({
|
|
saveMissionGoal: (...args: any[]) => mockSaveMissionGoal(...args),
|
|
getMissionGoal: (...args: any[]) => mockGetMissionGoal(...args),
|
|
clearMissionGoal: vi.fn(),
|
|
}));
|
|
|
|
const SAMPLE_QUESTION = {
|
|
id: "scope",
|
|
type: "single_select" as const,
|
|
question: "What is the target scope?",
|
|
description: "Pick the size for this mission.",
|
|
options: [
|
|
{ id: "mvp", label: "MVP" },
|
|
{ id: "full", label: "Full" },
|
|
],
|
|
};
|
|
|
|
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,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("MissionInterviewModal", () => {
|
|
let streamHandlers: any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
streamHandlers = undefined;
|
|
|
|
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
|
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
|
|
mockFetchAiSession.mockResolvedValue(null);
|
|
mockSaveMissionGoal.mockReset();
|
|
mockParseConversationHistory.mockImplementation((raw: string) => {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
streamHandlers = handlers;
|
|
return {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn().mockReturnValue(true),
|
|
};
|
|
});
|
|
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
|
mockReleaseSessionLock.mockResolvedValue(undefined);
|
|
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
|
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
|
localStorage.removeItem("floating-window:mission-interview");
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
|
|
callback(0);
|
|
return 1;
|
|
});
|
|
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
|
});
|
|
|
|
function renderModal(props: Partial<React.ComponentProps<typeof MissionInterviewModal>> = {}) {
|
|
const onClose = props.onClose ?? vi.fn();
|
|
|
|
return {
|
|
onClose,
|
|
...render(
|
|
<MissionInterviewModal
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
onMissionCreated={vi.fn()}
|
|
{...props}
|
|
/>,
|
|
),
|
|
};
|
|
}
|
|
|
|
function setViewport(width: number, height: number) {
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
|
|
}
|
|
|
|
function stubPointerCapture(element: HTMLElement) {
|
|
Object.defineProperty(element, "setPointerCapture", { configurable: true, value: vi.fn() });
|
|
Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() });
|
|
}
|
|
|
|
it("renders mission interview inside a floating desktop workspace", () => {
|
|
setViewport(1200, 900);
|
|
|
|
renderModal();
|
|
|
|
const panel = screen.getByTestId("floating-window-mission-interview");
|
|
expect(panel).toHaveClass("floating-window--mission-interview");
|
|
expect(panel).toHaveClass("floating-window--headerless");
|
|
expect(panel.style.width).toBe("760px");
|
|
expect(panel.style.height).toBe("680px");
|
|
expect(screen.queryByTestId("floating-window-drag-handle-mission-interview")).toBeNull();
|
|
expect(screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle")).toBeTruthy();
|
|
expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1);
|
|
});
|
|
|
|
it("drags and resizes the desktop mission floating window while clamping geometry", async () => {
|
|
setViewport(1200, 1000);
|
|
|
|
renderModal();
|
|
|
|
const panel = screen.getByTestId("floating-window-mission-interview");
|
|
const header = screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle") as HTMLElement;
|
|
stubPointerCapture(panel);
|
|
|
|
const initialLeft = Number.parseFloat(panel.style.left);
|
|
const initialTop = Number.parseFloat(panel.style.top);
|
|
|
|
act(() => {
|
|
fireEvent.pointerDown(header, { pointerId: 7, clientX: 120, clientY: 80 });
|
|
fireEvent.pointerMove(panel, { pointerId: 7, clientX: 220, clientY: 140 });
|
|
fireEvent.pointerUp(panel, { pointerId: 7, clientX: 220, clientY: 140 });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft);
|
|
expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop);
|
|
});
|
|
|
|
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement;
|
|
stubPointerCapture(resizeHandle);
|
|
|
|
act(() => {
|
|
fireEvent.pointerDown(resizeHandle, { pointerId: 8, clientX: 700, clientY: 600 });
|
|
fireEvent.pointerMove(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 });
|
|
fireEvent.pointerUp(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 });
|
|
});
|
|
|
|
expect(Number.parseFloat(panel.style.width)).toBeLessThanOrEqual(1200);
|
|
expect(Number.parseFloat(panel.style.height)).toBeLessThanOrEqual(1000);
|
|
expect(Number.parseFloat(panel.style.width)).toBeGreaterThanOrEqual(560);
|
|
expect(Number.parseFloat(panel.style.height)).toBeGreaterThanOrEqual(420);
|
|
});
|
|
|
|
it("keeps mobile mission planning full-screen and hides resize handles by CSS contract", () => {
|
|
const mobileBlock = missionInterviewCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--mission-interview \.mission-interview-modal\s*\{[\s\S]*?\n\}/)?.[0];
|
|
|
|
expect(mobileBlock).toContain(".floating-window--mission-interview");
|
|
expect(mobileBlock).toContain("width: 100vw !important;");
|
|
expect(mobileBlock).toContain("height: 100dvh !important;");
|
|
expect(mobileBlock).toContain(".floating-window--mission-interview .floating-window__resize-handle");
|
|
expect(mobileBlock).toContain("display: none;");
|
|
});
|
|
|
|
/*
|
|
FNXC:PlanningMultiTab 2026-07-14-00:00:
|
|
Mission interviews are multi-tab: this tab must never acquire a lock, never render a lock
|
|
overlay or "active in another tab" banner, and must stay interactive even when another tab
|
|
is using the same session.
|
|
*/
|
|
it("never acquires a tab lock and renders no lock overlay", async () => {
|
|
// A rejecting lock API would surface an overlay if any legacy lock path survived.
|
|
mockAcquireSessionLock.mockResolvedValue({ acquired: false, currentHolder: "tab-other" });
|
|
|
|
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(mockStartMissionInterview).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("session-active-another-tab-banner")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Take Control")).not.toBeInTheDocument();
|
|
expect(mockAcquireSessionLock).not.toHaveBeenCalled();
|
|
expect(mockForceAcquireSessionLock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows reconnecting indicator without clearing current question", async () => {
|
|
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(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined, undefined);
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
});
|
|
|
|
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
|
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("connected");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
});
|
|
|
|
it("preserves streaming thinking output while reconnecting", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onThinking?.("Analyzing mission goals...");
|
|
});
|
|
|
|
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
});
|
|
|
|
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
|
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
|
|
});
|
|
|
|
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?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders normalized generic stream failures as a recoverable retry state", 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?.("The mission interview stream was interrupted. Please retry the session.");
|
|
});
|
|
|
|
expect(await screen.findByText("The mission interview stream was interrupted. Please retry the session.")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("AI is thinking...")).not.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) => {
|
|
streamHandlers = handlers;
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
setTimeout(() => handlers.onError?.("Try again"), 10);
|
|
} else {
|
|
setTimeout(() => handlers.onQuestion?.(SAMPLE_QUESTION), 10);
|
|
}
|
|
return {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn().mockReturnValue(true),
|
|
};
|
|
});
|
|
|
|
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("Try again")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
});
|
|
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("recovers connection-loss directly 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),
|
|
};
|
|
});
|
|
|
|
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(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
|
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
|
|
expect(screen.getByText("Continuing...")).toBeInTheDocument();
|
|
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 () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
fireEvent.click(await screen.findByText("MVP"));
|
|
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
|
|
target: { value: "Optimize for launch speed" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
expect.objectContaining({ scope: "mvp", _comment: "Optimize for launch speed" }),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("submits trimmed Other-only answers for single-select mission questions", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Start with discovery instead " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Start with discovery instead" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("renders Other for single-select mission questions with no provided options", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "open_scope",
|
|
type: "single_select",
|
|
question: "What scope should we use?",
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Define a custom scope " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Define a custom scope" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("clears stale Other text when switching back to a provided mission option", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
|
|
expect(continueButton).toBeDisabled();
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: "Plan a discovery mission" },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
|
|
fireEvent.click(screen.getByText("MVP"));
|
|
expect(screen.queryByTestId("planning-other-input")).toBeNull();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ scope: "mvp" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("submits Other-only answers for multi-select mission questions", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "priorities",
|
|
type: "multi_select",
|
|
question: "Which priorities matter?",
|
|
options: [
|
|
{ id: "speed", label: "Speed" },
|
|
{ id: "quality", label: "Quality" },
|
|
],
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Add field research first " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Add field research first" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("renders Other for multi-select mission questions with no provided options", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "open_priorities",
|
|
type: "multi_select",
|
|
question: "Which priorities matter?",
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Ask customers first " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Ask customers first" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("combines provided options with Other text for multi-select mission questions", async () => {
|
|
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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "priorities",
|
|
type: "multi_select",
|
|
question: "Which priorities matter?",
|
|
options: [
|
|
{ id: "speed", label: "Speed" },
|
|
{ id: "quality", label: "Quality" },
|
|
],
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByText("Speed"));
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Preserve operator review " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ priorities: ["speed"], _other: "Preserve operator review" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("restores persisted goal from localStorage on open", () => {
|
|
mockGetMissionGoal.mockReturnValue("Previous mission goal");
|
|
|
|
renderModal();
|
|
|
|
const textarea = screen.getByLabelText("What do you want to build?");
|
|
expect(textarea).toHaveValue("Previous mission goal");
|
|
});
|
|
|
|
it("closes without cancelling an in-progress interview and renders only one close button", async () => {
|
|
const { onClose } = 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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
const closeButtons = screen.getAllByRole("button", { name: "Close" });
|
|
expect(closeButtons).toHaveLength(1);
|
|
expect(screen.queryByRole("button", { name: "Send to background" })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(closeButtons[0]);
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists the draft goal and closes from the initial view", () => {
|
|
const { onClose } = renderModal({ projectId: "proj-1" });
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Draft mission goal" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
|
|
expect(mockSaveMissionGoal).toHaveBeenCalledWith("Draft mission goal", "proj-1");
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes without cancelling when pressing Escape", async () => {
|
|
const { onClose } = 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();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not render a blocking backdrop click target around the floating workspace", () => {
|
|
const { onClose } = renderModal();
|
|
const overlay = screen.getByRole("dialog");
|
|
|
|
fireEvent.mouseDown(overlay);
|
|
fireEvent.click(overlay);
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows typing in textarea without resetting to stale persisted goal", async () => {
|
|
// Simulate a stale persisted goal from a previous session
|
|
mockGetMissionGoal.mockReturnValue("Old stale goal");
|
|
|
|
renderModal();
|
|
|
|
const textarea = screen.getByLabelText("What do you want to build?");
|
|
expect(textarea).toHaveValue("Old stale goal");
|
|
|
|
// User starts typing a new goal
|
|
fireEvent.change(textarea, { target: { value: "New mission" } });
|
|
expect(textarea).toHaveValue("New mission");
|
|
|
|
// Type more characters — the stale value should NOT overwrite
|
|
fireEvent.change(textarea, { target: { value: "New mission idea" } });
|
|
expect(textarea).toHaveValue("New mission idea");
|
|
|
|
// Even after a re-render cycle, user input should persist
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
});
|
|
expect(textarea).toHaveValue("New mission idea");
|
|
});
|
|
});
|