Files
fusion/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.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

1581 lines
53 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";
const mockUseAiSessionSync = vi.fn();
import {
mockStartPlanning,
mockStartPlanningStreaming,
mockCreatePlanningDraft,
mockConnectPlanningStream,
mockRespondToPlanning,
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),
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),
}));
vi.mock("../../hooks/useAiSessionSync", () => ({
useAiSessionSync: (...args: any[]) => mockUseAiSessionSync(...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" });
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 });
mockUseAiSessionSync.mockReturnValue({
activeTabMap: new Map(),
broadcastUpdate: vi.fn(),
broadcastCompleted: vi.fn(),
broadcastLock: vi.fn(),
broadcastUnlock: vi.fn(),
broadcastHeartbeat: vi.fn(),
});
// 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("Planning flow", () => {
it("starts planning and shows question view", async () => {
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"));
// Wait for streaming to be called
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
});
// Should transition to question view via streaming
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
});
it("shows locked overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
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.getByTestId("session-lock-overlay")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByText("Take Control"));
});
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
});
});
it("does not render duplicate inline lock text while takeover overlay handles lock state", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
mockUseAiSessionSync.mockReturnValueOnce({
activeTabMap: new Map([
[
"session-123",
{
tabId: "tab-other",
stale: false,
},
],
]),
broadcastUpdate: vi.fn(),
broadcastCompleted: vi.fn(),
broadcastLock: vi.fn(),
broadcastUnlock: vi.fn(),
broadcastHeartbeat: vi.fn(),
});
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.getByTestId("session-lock-overlay")).toBeDefined();
});
expect(screen.getByText("This session is active in another tab")).toBeDefined();
expect(screen.queryByText("Session is active in another tab.")).toBeNull();
expect(screen.getByRole("button", { name: "Take Control" })).toBeDefined();
});
it("allows normal question interaction when lock is acquired", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
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.queryByTestId("session-lock-overlay")).toBeNull();
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
fireEvent.click(screen.getByText("Small"));
fireEvent.click(screen.getByText("Continue"));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ "q-scope": "small" },
undefined,
"tab-self",
);
});
});
it("shows stop action in loading and stops generation", async () => {
let streamHandlers: any;
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: closeSpy,
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.getByRole("button", { name: "Stop" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await waitFor(() => {
expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
expect(closeSpy).toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByText("Generation stopped by user. You can retry or start a new session.")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
// avoid dangling handlers reference lint
expect(streamHandlers).toBeDefined();
});
it("shows error message when planning fails", async () => {
// Override the default mock to simulate an error
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onError?.("Rate limit exceeded");
}, 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("Rate limit exceeded")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
});
it("retries from error state and reconnects stream", async () => {
let streamAttempt = 0;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Temporary failure"), 10);
} else {
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}
/>,
);
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("Temporary failure")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
it("auto-recovers from a stream error when server session is still generating", async () => {
let streamAttempt = 0;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Connection lost"), 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValueOnce({
id: "session-123",
type: "planning",
status: "generating",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "Still thinking...",
error: null,
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: 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"));
// No manual retry button — onError silently re-fetches the session,
// sees status="generating", and reconnects without surfacing the error.
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-123");
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("Connection lost")).toBeNull();
});
it("auto-recovers from a stream error when server session is awaiting input", async () => {
let streamAttempt = 0;
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamAttempt += 1;
if (streamAttempt === 1) {
setTimeout(() => handlers.onError?.("Connection lost"), 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockFetchAiSession.mockResolvedValueOnce({
id: "session-123",
type: "planning",
status: "awaiting_input",
title: "Build auth system",
inputPayload: JSON.stringify({ initialPlan: "Build auth system" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify(mockQuestion),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: 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"));
// Silent recovery: onError re-fetches the session, sees status=
// "awaiting_input", and reconnects without surfacing the error.
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-123");
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("Connection lost")).toBeNull();
});
});
describe("Resuming complete sessions", () => {
it("shows summary view when resuming a complete persisted session", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-ready planning output",
description: "Recovered summary description from persisted session",
suggestedSize: "L",
suggestedDependencies: ["FN-001"],
keyDeliverables: ["Deliverable A", "Deliverable B"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-1",
type: "planning",
status: "complete",
title: "Resume-ready planning output",
inputPayload: JSON.stringify({ initialPlan: "Build resilient planning resume" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
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-complete-1"
/>
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-complete-1");
});
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
expect(screen.getByDisplayValue("Recovered summary description from persisted session")).toBeDefined();
expect((screen.getByRole("combobox", { name: "Suggested Size" }) as HTMLSelectElement).value).toBe("L");
expect(screen.getByText("Deliverable A")).toBeDefined();
expect(screen.getByText("Deliverable B")).toBeDefined();
});
it("restores the textarea and reattaches the draft id when reopening a persisted draft", async () => {
const draftPlan = "Persisted draft text the user typed before closing the modal";
mockFetchAiSession.mockResolvedValueOnce({
id: "session-draft-1",
type: "planning",
status: "draft",
title: "New planning session",
inputPayload: JSON.stringify({ initialPlan: draftPlan }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
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-draft-1"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-draft-1");
});
// The draft is restored to the editor (initial view), not surfaced as a
// question or summary, and the textarea contains exactly the persisted
// initialPlan so the user can keep editing or click Start Planning.
const textarea = await screen.findByDisplayValue(draftPlan);
expect((textarea as HTMLTextAreaElement).tagName).toBe("TEXTAREA");
expect(screen.getByText("Start Planning")).toBeDefined();
});
it("restores the persisted model override when reopening a draft so Start Planning uses it", async () => {
// The draft was created under an explicit anthropic/claude-opus model.
// Reopening must restore that selection into the modal's local state
// so a subsequent Start Planning click uses it instead of silently
// falling back to whatever the dropdown currently defaults to. The
// server-side round-trip is covered separately in planning.test.ts;
// this test pins the React-state restoration.
mockFetchAiSession.mockResolvedValueOnce({
id: "session-draft-with-model",
type: "planning",
status: "draft",
title: "New planning session",
inputPayload: JSON.stringify({
initialPlan: "Plan that needs a specific model",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
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-draft-with-model"
/>,
);
// Wait for the textarea to be populated from the draft — proves the
// reopen path ran and the modal is in the editable initial view.
await screen.findByDisplayValue("Plan that needs a specific model");
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
"Plan that needs a specific model",
undefined,
{ planningModelProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
{ planningDepth: "medium", customQuestionCount: undefined },
"session-draft-with-model",
);
});
});
it("shows retry panel when resuming an errored session", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-error-1",
type: "planning",
status: "error",
title: "Errored planning",
inputPayload: JSON.stringify({ initialPlan: "Recover planning" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: "Session interrupted",
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-error-1"
/>,
);
await waitFor(() => {
expect(screen.getByText("Session interrupted")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
});
it("creates a task from a resumed complete session", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-task",
description: "Recovered summary for task creation",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-2",
type: "planning",
status: "complete",
title: "Resume-to-task",
inputPayload: JSON.stringify({ initialPlan: "Recover and create" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockCreateTaskFromPlanning.mockResolvedValueOnce({
id: "FN-100",
title: "Resume-to-task",
description: "Recovered summary for task creation",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
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-complete-2"
/>
);
await waitFor(() => {
expect(screen.getByText("Create Single Task")).toBeDefined();
});
const createSingleTaskButton = screen.getByRole("button", { name: "Create Single Task" });
const breakIntoTasksButton = screen.getByRole("button", { name: "Break into Tasks" });
expect(createSingleTaskButton.className).toContain("btn");
expect(createSingleTaskButton.className).not.toContain("btn-primary");
expect(breakIntoTasksButton.className).toContain("btn-primary");
fireEvent.click(createSingleTaskButton);
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
"session-complete-2",
expect.objectContaining({ ...resumedSummary, priority: "normal" }),
undefined,
);
});
});
it("submits selected summary priority when creating a single task", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-task-priority",
description: "Recovered summary for priority",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-priority",
type: "planning",
status: "complete",
title: "Resume-to-task-priority",
inputPayload: JSON.stringify({ initialPlan: "Recover and create with priority" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
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-complete-priority"
/>
);
await waitFor(() => {
expect(screen.getByText("Create Single Task")).toBeDefined();
});
fireEvent.change(screen.getByRole("combobox", { name: "Priority" }), {
target: { value: "high" },
});
fireEvent.click(screen.getByRole("button", { name: "Create Single Task" }));
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
"session-complete-priority",
expect.objectContaining({ priority: "high" }),
undefined,
);
});
});
it("preserves per-subtask priority selections when creating tasks from breakdown", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-breakdown-priority",
description: "Recovered summary for breakdown priority",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-breakdown-priority",
type: "planning",
status: "complete",
title: "Resume-to-breakdown-priority",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down with priority" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-breakdown-priority",
subtasks: [
{
id: "subtask-1",
title: "First subtask",
description: "First description",
suggestedSize: "M",
dependsOn: [],
},
],
});
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-breakdown-priority"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
});
const prioritySelect = screen.getAllByRole("combobox", { name: "Priority" })[0];
fireEvent.change(prioritySelect, { target: { value: "urgent" } });
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-breakdown-priority",
[{ id: "subtask-1", priority: "urgent" }],
undefined,
);
});
});
it("sends only edited breakdown fields when creating tasks", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-breakdown-compact",
description: "Recovered summary for compact breakdown",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-breakdown-compact",
type: "planning",
status: "complete",
title: "Resume-to-breakdown-compact",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down compactly" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-breakdown-compact",
subtasks: [
{
id: "subtask-1",
title: "First subtask",
description: "First description",
suggestedSize: "M",
dependsOn: [],
},
{
id: "subtask-2",
title: "Second subtask",
description: "Second description",
suggestedSize: "S",
dependsOn: ["subtask-1"],
},
],
});
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-breakdown-compact"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
});
const firstSubtask = screen.getByTestId("subtask-item-0");
fireEvent.change(within(firstSubtask).getAllByRole("textbox")[0]!, {
target: { value: "Edited first subtask" },
});
const secondSubtask = screen.getByTestId("subtask-item-1");
fireEvent.change(within(secondSubtask).getAllByRole("textbox")[1]!, {
target: { value: "Edited second description" },
});
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-breakdown-compact",
[
{ id: "subtask-1", title: "Edited first subtask" },
{ id: "subtask-2", description: "Edited second description" },
],
undefined,
);
});
});
it("includes client-added subtasks in the compact create-tasks payload", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-breakdown-add-subtask",
description: "Recovered summary for added subtask",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-breakdown-add-subtask",
type: "planning",
status: "complete",
title: "Resume-to-breakdown-add-subtask",
inputPayload: JSON.stringify({ initialPlan: "Recover and add a subtask" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-breakdown-add-subtask",
subtasks: [
{
id: "subtask-1",
title: "Existing subtask",
description: "Existing description",
suggestedSize: "M",
dependsOn: [],
},
],
});
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-breakdown-add-subtask"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Add subtask" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Add subtask" }));
const addedSubtask = screen.getByTestId("subtask-item-1");
const addedTextboxes = within(addedSubtask).getAllByRole("textbox");
fireEvent.change(addedTextboxes[0]!, { target: { value: "Rollout follow-up" } });
fireEvent.change(addedTextboxes[1]!, { target: { value: "Prepare rollout notes" } });
fireEvent.change(within(addedSubtask).getByLabelText("Size"), { target: { value: "S" } });
fireEvent.change(within(addedSubtask).getByLabelText("Priority"), { target: { value: "high" } });
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-breakdown-add-subtask",
[
{ id: "subtask-1" },
{
id: "subtask-2",
title: "Rollout follow-up",
description: "Prepare rollout notes",
suggestedSize: "S",
priority: "high",
dependsOn: [],
},
],
undefined,
);
});
});
it("omits removed generated subtasks from the compact create-tasks payload", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-to-breakdown-remove-subtask",
description: "Recovered summary for removed subtask",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-breakdown-remove-subtask",
type: "planning",
status: "complete",
title: "Resume-to-breakdown-remove-subtask",
inputPayload: JSON.stringify({ initialPlan: "Recover and remove a subtask" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-breakdown-remove-subtask",
subtasks: [
{
id: "subtask-1",
title: "Existing subtask",
description: "Existing description",
suggestedSize: "M",
dependsOn: [],
},
{
id: "subtask-2",
title: "Generated follow-up",
description: "Generated follow-up description",
suggestedSize: "S",
dependsOn: ["subtask-1"],
},
],
});
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-breakdown-remove-subtask"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
});
const secondSubtask = screen.getByTestId("subtask-item-1");
fireEvent.click(within(secondSubtask).getByRole("button", { name: "Remove" }));
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-breakdown-remove-subtask",
[{ id: "subtask-1" }],
undefined,
);
});
});
it("refines a resumed complete session without blank question view", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-and-refine",
description: "Recovered summary for refine",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
const refinedQuestion: PlanningQuestion = {
id: "q-refine",
type: "text",
question: "Which part should we refine?",
description: "Refine follow-up",
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-refine",
type: "planning",
status: "complete",
title: "Resume-and-refine",
inputPayload: JSON.stringify({ initialPlan: "Recover and refine" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementationOnce(async () => {
setTimeout(() => {
streamHandlers?.onQuestion?.(refinedQuestion);
}, 10);
return { type: "question", data: refinedQuestion };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-complete-refine"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Refine Further" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Refine Further" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-complete-refine",
{ refine: true },
undefined,
expect.any(String),
);
});
await waitFor(() => {
expect(screen.getByText("Which part should we refine?")).toBeDefined();
});
expect(screen.queryByText("No active question in session")).toBeNull();
});
});
describe("Conversation history", () => {
it("hides completed-session Q&A by default behind a summary disclosure", async () => {
const resumedSummary: PlanningSummary = {
title: "Summary with hidden history",
description: "Recovered summary description",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Deliverable A"],
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope do you need?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { q1: "medium" },
thinkingOutput: "Reasoning for scope question",
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-with-history",
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
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-complete-with-history"
/>,
);
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Show user Q&A" })).toBeDefined();
expect(screen.queryByTestId("conversation-history")).toBeNull();
expect(screen.queryByText("What scope do you need?")).toBeNull();
expect(screen.queryByText("Medium")).toBeNull();
});
it("reveals completed-session Q&A when summary disclosure is expanded", async () => {
const resumedSummary: PlanningSummary = {
title: "Summary with expandable history",
description: "Recovered summary description",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Deliverable A"],
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope do you need?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { q1: "medium" },
thinkingOutput: "Reasoning for scope question",
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-with-history-2",
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
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-complete-with-history-2"
/>,
);
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" }));
await waitFor(() => {
expect(screen.getByTestId("conversation-history")).toBeDefined();
});
expect(screen.getByText("What scope do you need?")).toBeDefined();
expect(within(screen.getByTestId("conversation-history")).getByText("Medium")).toBeDefined();
});
it("restores all persisted Q&A pairs when resuming a 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?",
description: "Current question",
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope do you need?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { q1: "medium" },
thinkingOutput: "Reasoning for scope question",
},
{
question: {
id: "q2",
type: "text",
question: "List your acceptance criteria",
},
response: { q2: "Must support offline mode" },
thinkingOutput: "Reasoning for criteria question",
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-awaiting-1",
type: "planning",
status: "awaiting_input",
title: "Resume with history",
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
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",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-awaiting-1"
/>,
);
await waitFor(() => {
expect(mockParseConversationHistory).toHaveBeenCalledWith(JSON.stringify(restoredHistory));
});
await waitFor(() => {
expect(screen.getByText("What scope do you need?")).toBeDefined();
});
expect(screen.getByText("List your acceptance criteria")).toBeDefined();
expect(within(screen.getByTestId("conversation-history")).getByText("Medium")).toBeDefined();
expect(screen.getByText("Must support offline mode")).toBeDefined();
expect(screen.getByText("What should we prioritize next?")).toBeDefined();
});
it("starts fresh sessions with empty conversation history", 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 waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
expect(screen.queryByTestId("conversation-history")).toBeNull();
});
it("appends submitted responses to visible conversation history", async () => {
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
description: "Describe the requirements",
};
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),
};
});
mockRespondToPlanning.mockImplementation(async () => {
setTimeout(() => {
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"));
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
const mediumOption = await screen.findByText("Medium");
fireEvent.click(mediumOption);
const continueBtn = await screen.findByRole("button", { name: "Continue" });
fireEvent.click(continueBtn);
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
}, { timeout: 5000 });
expect(screen.getByTestId("conversation-history")).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();
expect(screen.getByText("Medium")).toBeDefined();
});
});
});