Files
fusion/packages/dashboard/app/components/__tests__/PlanningModeModal.questions.test.tsx
Fusion efdc6c4625 feat(FN-4139): add swipe-back navigation to chat, mission, and planning flo
Implements swipe-back gesture support for mobile navigation across ChatView, MissionManager, and PlanningModeModal, wired through a shared navigation history context and covered by a comprehensive test suite (~548 lines of swipe-back tests). A small fixup ensures swipe-back behaves correctly when la

Fusion-Task-Id: FN-4139
2026-05-12 17:04:33 -07:00

1169 lines
40 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
import * as api from "../../api";
import { PlanningModeModal } from "../PlanningModeModal";
import { TaskDetailModal } from "../TaskDetailModal";
import { useSessionLock } from "../../hooks/useSessionLock";
import { getSessionTabId } from "../../utils/getSessionTabId";
import type { MergeResult } from "@fusion/core";
import {
mockStartPlanning,
mockStartPlanningStreaming,
mockCreatePlanningDraft,
mockConnectPlanningStream,
mockRespondToPlanning,
mockRewindPlanningSession,
mockRetryPlanningSession,
mockCancelPlanning,
mockStopPlanningGeneration,
mockUpdatePlanningSessionDraft,
mockCreateTaskFromPlanning,
mockStartPlanningBreakdown,
mockCreateTasksFromPlanning,
mockFetchAiSession,
mockParseConversationHistory,
mockFetchModels,
mockAcquireSessionLock,
mockReleaseSessionLock,
mockForceAcquireSessionLock,
mockUploadAttachment,
mockDeleteAttachment,
mockUpdateTask,
mockPauseTask,
mockUnpauseTask,
mockFetchTaskDetail,
mockRequestSpecRevision,
mockApprovePlan,
mockRejectPlan,
mockRefineTask,
mockFetchAiSessions,
mockConfirm,
mockUseViewportMode,
mockUseMobileKeyboard,
mockTasks,
mockModels,
mockQuestion,
mockSummary,
mockTaskDetail,
MockEventSource,
getMediaBlocks,
mockViewport,
} from "./PlanningModeModal.test-helpers";
vi.mock("../../api", () => ({
startPlanning: (...args: any[]) => mockStartPlanning(...args),
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
rewindPlanningSession: (...args: any[]) => mockRewindPlanningSession(...args),
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args), cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...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),
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
updateTask: (...args: any[]) => mockUpdateTask(...args),
pauseTask: (...args: any[]) => mockPauseTask(...args),
unpauseTask: (...args: any[]) => mockUnpauseTask(...args),
fetchTaskDetail: (...args: any[]) => mockFetchTaskDetail(...args),
requestSpecRevision: (...args: any[]) => mockRequestSpecRevision(...args),
approvePlan: (...args: any[]) => mockApprovePlan(...args),
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
refineTask: (...args: any[]) => mockRefineTask(...args),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchModels: (...args: any[]) => mockFetchModels(...args),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
duplicateTask: vi.fn().mockResolvedValue({}),
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => mockUseViewportMode(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
}));
describe("PlanningModeModal", () => {
const mockOnClose = vi.fn();
const mockOnTaskCreated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
// Default to desktop viewport; mobile-specific tests override per-test.
mockViewport("desktop");
// Default mock for streaming
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
// Server's createDraftSession always returns the placeholder title; the
// real summarized title only arrives later via blur/close summarize or
// when the session transitions out of draft. Mirror that in the mock so
// the sidebar render rule (preview while title === placeholder) behaves
// realistically in tests.
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
mockRewindPlanningSession.mockResolvedValue({ currentQuestion: mockQuestion, history: [] });
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
mockFetchAiSession.mockResolvedValue(null);
mockFetchAiSessions.mockResolvedValue([]);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
resolvedPlanningProvider: "openai",
resolvedPlanningModelId: "gpt-4o",
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockCancelPlanning.mockResolvedValue(undefined);
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
mockStopPlanningGeneration.mockResolvedValue({ success: true });
// Default: simulate receiving a question after a brief delay
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
});
describe("Initial-turn reasoning visibility (FN-3274)", () => {
it("preserves reasoning in conversation history when first question arrives after thinking", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
// Wait for loading state to appear
await waitFor(() => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
// Simulate thinking output arriving during loading
act(() => {
streamHandlers.onThinking?.("Analyzing the plan requirements...");
});
await waitFor(() => {
expect(screen.getByText("AI is thinking...")).toBeDefined();
});
// Transition to question view
act(() => {
streamHandlers.onQuestion?.(mockQuestion);
});
// Question should be visible
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// The reasoning should now be in conversation history as an expandable entry
expect(screen.getByTestId("conversation-history")).toBeDefined();
expect(screen.getByText("AI Reasoning")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
expect(screen.getByText("Analyzing the plan requirements...")).toBeDefined();
// avoid dangling handlers reference lint
expect(streamHandlers).toBeDefined();
});
it("preserves reasoning in conversation history when summary arrives after thinking", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
// Simulate thinking output arriving
act(() => {
streamHandlers.onThinking?.("Finalizing the planning summary...");
});
await waitFor(() => {
expect(screen.getByText("AI is thinking...")).toBeDefined();
});
// Transition directly to summary view
act(() => {
streamHandlers.onSummary?.(mockSummary);
});
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
// The reasoning should be visible in the Q&A disclosure
fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" }));
await waitFor(() => {
expect(screen.getByTestId("conversation-history")).toBeDefined();
});
expect(screen.getByText("AI Reasoning")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
expect(screen.getByText("Finalizing the planning summary...")).toBeDefined();
expect(streamHandlers).toBeDefined();
});
it("restores persisted thinkingOutput as conversation history when resuming awaiting_input session", async () => {
mockConnectPlanningStream.mockImplementationOnce(() => ({
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
}));
const resumedQuestion: PlanningQuestion = {
id: "q-current",
type: "text",
question: "What should we prioritize next?",
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope?",
options: [{ id: "small", label: "Small" }],
},
response: { q1: "small" },
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-awaiting-reasoning",
type: "planning",
status: "awaiting_input",
title: "Resume with reasoning",
inputPayload: JSON.stringify({ initialPlan: "Build planning with reasoning" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: JSON.stringify(resumedQuestion),
result: null,
thinkingOutput: "Server-side reasoning captured during generation",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-awaiting-reasoning"
/>,
);
await waitFor(() => {
expect(screen.getByText("What should we prioritize next?")).toBeDefined();
});
// The persisted thinkingOutput should appear as a conversation history entry
const history = screen.getByTestId("conversation-history");
expect(history).toBeDefined();
// Should show the existing Q&A plus the AI Reasoning entry
expect(screen.getByText("What scope?")).toBeDefined();
expect(screen.getByText("AI Reasoning")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
expect(screen.getByText("Server-side reasoning captured during generation")).toBeDefined();
});
it("does not create duplicate reasoning entries on repeated transitions", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
const secondQuestion: PlanningQuestion = {
id: "q-second",
type: "text",
question: "Any additional requirements?",
};
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
// Emit thinking then question
act(() => {
streamHandlers.onThinking?.("First reasoning block");
});
act(() => {
streamHandlers.onQuestion?.(mockQuestion);
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Answer the question
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalled();
});
// Simulate thinking for second question then emit second question
act(() => {
streamHandlers.onThinking?.("Second reasoning block");
});
act(() => {
streamHandlers.onQuestion?.(secondQuestion);
});
await waitFor(() => {
expect(screen.getByText("Any additional requirements?")).toBeDefined();
});
// Conversation history should contain both reasoning entries without duplicates
const history = screen.getByTestId("conversation-history");
expect(history).toBeDefined();
// Should have Q1, reasoning1, reasoning2 entries
const reasoningButtons = screen.getAllByRole("button", { name: /Show AI reasoning/i });
// First reasoning button should be next to Q1, second should be standalone
// There should be exactly 2 reasoning entries (not duplicated)
expect(reasoningButtons.length).toBe(2);
expect(streamHandlers).toBeDefined();
});
it("preserves reasoning when answer submission transitions back to loading then question", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
};
mockRespondToPlanning.mockImplementation(async () => {
// Simulate thinking then second question via the existing stream
setTimeout(() => {
streamHandlers?.onThinking?.("Thinking about requirements...");
streamHandlers?.onQuestion?.(secondQuestion);
}, 10);
return { sessionId: "session-123", currentQuestion: null, summary: null };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
// Wait for first thinking and question
await waitFor(() => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
act(() => {
streamHandlers.onThinking?.("Initial analysis...");
});
act(() => {
streamHandlers.onQuestion?.(mockQuestion);
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Answer the first question
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
// Wait for second question to arrive
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
}, { timeout: 3000 });
// Conversation history should contain the first Q&A pair and initial reasoning
const history = screen.getByTestId("conversation-history");
expect(history).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();
expect(screen.getByText("Medium")).toBeDefined();
expect(streamHandlers).toBeDefined();
});
});
describe("Question view", () => {
it("renders single_select question with options", async () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Small")).toBeDefined();
expect(screen.getByText("Medium")).toBeDefined();
expect(screen.getByText("Large")).toBeDefined();
});
expect(container.querySelector(".planning-question-form > .planning-view-scroll")).not.toBeNull();
expect(container.querySelector(".planning-question-form > .planning-actions")).not.toBeNull();
});
it("shows comment textarea for single_select questions", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
expect(await screen.findByPlaceholderText("Add any extra context or direction...")).toBeInTheDocument();
});
it("does not show comment textarea for text questions", async () => {
const textQuestion: PlanningQuestion = {
id: "q-text",
type: "text",
question: "Describe your requirements",
};
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.(textQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await screen.findByText("Describe your requirements");
expect(screen.queryByPlaceholderText("Add any extra context or direction...")).not.toBeInTheDocument();
});
it("rewinds to the previous question when Back is clicked", async () => {
let streamHandlers: any;
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
};
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementationOnce(async () => {
setTimeout(() => {
streamHandlers?.onQuestion?.(secondQuestion);
}, 10);
return { type: "question", data: secondQuestion };
});
mockRewindPlanningSession.mockResolvedValueOnce({
currentQuestion: mockQuestion,
history: [],
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await screen.findByText("What is the scope?");
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByText("What are the key requirements?");
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() => {
expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
expect(await screen.findByText("What is the scope?")).toBeInTheDocument();
expect(screen.queryByText("What are the key requirements?")).toBeNull();
});
it("includes _comment in response when comment is filled", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await screen.findByText("What is the scope?");
fireEvent.click(screen.getByText("Medium"));
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
target: { value: "Prioritize API first" },
});
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
expect.objectContaining({ "q-scope": "medium", _comment: "Prioritize API first" }),
undefined,
expect.any(String),
);
});
});
it("omits _comment when comment is empty", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await screen.findByText("What is the scope?");
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
expect.not.objectContaining({ _comment: expect.anything() }),
undefined,
expect.any(String),
);
});
});
it("shows reconnecting indicator without clearing current question state", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
});
await waitFor(() => {
expect(screen.queryByText("Reconnecting…")).toBeNull();
});
expect(screen.getByText("What is the scope?")).toBeDefined();
});
it("receives second question after answering first without hanging (race condition fix)", async () => {
// Use fake timers to avoid CI flakiness from tiny setTimeout delays in this race-condition scenario.
vi.useFakeTimers();
try {
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
description: "Describe the requirements",
};
// Track how many times connectPlanningStream is called
let streamConnectionCount = 0;
let streamHandlers: any = null;
let deliverSecondQuestion: (() => void) | null = null;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamConnectionCount++;
streamHandlers = handlers;
// Emit the first question synchronously on initial connection.
if (streamConnectionCount === 1) {
handlers.onQuestion?.(mockQuestion);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementation(async () => {
return new Promise((resolve) => {
deliverSecondQuestion = () => {
streamHandlers?.onQuestion?.(secondQuestion);
resolve({ sessionId: "session-123", currentQuestion: null, summary: null });
};
});
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText("What is the scope?")).toBeDefined();
// Answer the first question.
fireEvent.click(screen.getByText("Medium"));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton.hasAttribute("disabled")).toBe(false);
fireEvent.click(continueButton);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockRespondToPlanning).toHaveBeenCalledTimes(1);
expect(deliverSecondQuestion).not.toBeNull();
act(() => {
deliverSecondQuestion?.();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Verify second question appears without hanging.
expect(screen.getByText("What are the key requirements?")).toBeDefined();
// Verify SSE connection was established only ONCE (not reconnected).
// This confirms the race condition fix - the same connection is reused.
expect(streamConnectionCount).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("connects to stream when resuming awaiting_input session to receive real-time updates", async () => {
// This test verifies the fix for the mismatch where a session was advertised as
// needing input but the resume path initially entered loading state.
// The modal should connect to the stream for awaiting_input sessions to receive
// real-time updates (thinking output, next question, etc.).
const resumedQuestion: PlanningQuestion = {
id: "q-priority",
type: "single_select",
question: "What's your priority?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
{ id: "cost", label: "Cost" },
],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-awaiting-stream-1",
type: "planning",
status: "awaiting_input",
title: "Resume with stream",
inputPayload: JSON.stringify({ initialPlan: "Build planning with stream" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify(resumedQuestion),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
// Track stream connections
let streamConnectedSessionId: string | null = null;
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, _projectId: string | undefined, _handlers: any) => {
streamConnectedSessionId = sessionId;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-awaiting-stream-1"
/>,
);
// Flush React state updates from the resume effect
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Session should be fetched
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-awaiting-stream-1");
});
// Question should appear immediately from session data
await waitFor(() => {
expect(screen.getByText("What's your priority?")).toBeDefined();
});
// Modal should connect to the stream for real-time updates
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
// Verify stream connection was established
expect(mockConnectPlanningStream).toHaveBeenCalled();
expect(streamConnectedSessionId).toBe("session-awaiting-stream-1");
// Should NOT be stuck in loading state
expect(screen.queryByText("Generating next question...")).toBeNull();
});
});
describe("Summary view", () => {
it("shows summary when planning is complete", async () => {
// Override mock to return summary instead of question
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
expect(container.querySelector(".planning-summary > .planning-view-scroll")).not.toBeNull();
expect(container.querySelector(".planning-summary > .planning-actions")).not.toBeNull();
expect(container.querySelector(".planning-summary .planning-deps-list")).not.toBeNull();
});
it("renders and updates summary size dropdown", async () => {
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
const sizeSelect = screen.getByLabelText("Suggested Size") as HTMLSelectElement;
expect(sizeSelect.value).toBe("M");
expect(Array.from(sizeSelect.options).map((option) => option.textContent)).toEqual([
"S (Small)",
"M (Medium)",
"L (Large)",
]);
fireEvent.change(sizeSelect, { target: { value: "L" } });
expect(sizeSelect.value).toBe("L");
});
it("creates task from summary", async () => {
const createdTask: Task = {
id: "FN-042",
title: "Build authentication system",
description: "Implement user auth with login and signup",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
// Override mock to return summary
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockCreateTaskFromPlanning.mockResolvedValue(createdTask);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Create Single Task")).toBeDefined();
});
fireEvent.click(screen.getByText("Create Single Task"));
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123", mockSummary, undefined);
expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask);
});
});
});
describe("Breakdown view", () => {
it("renders and updates subtask size dropdown", async () => {
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockStartPlanningBreakdown.mockResolvedValue({
sessionId: "session-123",
subtasks: [
{
id: "subtask-1",
title: "Design auth schema",
description: "Design the auth data model",
suggestedSize: "M",
dependsOn: [],
},
{
id: "subtask-2",
title: "Implement auth endpoints",
description: "Create login/signup endpoints",
suggestedSize: "S",
dependsOn: ["subtask-1"],
},
],
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
fireEvent.click(screen.getByText("Break into Tasks"));
await waitFor(() => {
expect(mockStartPlanningBreakdown).toHaveBeenCalledWith("session-123", mockSummary, undefined);
});
await waitFor(() => {
expect(screen.getByText("Create Tasks")).toBeDefined();
});
const firstSubtask = screen.getByTestId("subtask-item-0");
const sizeSelect = within(firstSubtask).getByLabelText("Size") as HTMLSelectElement;
expect(sizeSelect.value).toBe("M");
expect(Array.from(sizeSelect.options).map((option) => option.textContent)).toEqual([
"S",
"M",
"L",
]);
fireEvent.change(sizeSelect, { target: { value: "L" } });
expect(sizeSelect.value).toBe("L");
fireEvent.click(screen.getByText("Create Tasks"));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-123",
[
{ id: "subtask-1", suggestedSize: "L" },
{ id: "subtask-2" },
],
undefined,
);
});
});
});
});