Files
fusion/packages/dashboard/app/components/__tests__/PlanningModeModal.test.tsx
gsxdsm 98d2e1d795 fix(dashboard): show summarized draft title in sidebar and dedup start-path summarize
Closes the loop on the blur/close summarize work. Previously the sidebar
always rendered `session.preview` for drafts, so the model call fired by
blur/close updated `session.title` to a value the user never saw, and
then `startExistingSession` summarized again on Start Planning even when
blur had already produced a title for the exact same text.

The sidebar now shows the summarized title once available and falls
back to the inputPayload preview only while the title is still the
placeholder. summarizeDraftTitle persists the plan text it summarized
from via a new `markDraftSummarized` store method, and updateDraft
preserves that marker only while the persisted text still matches it
(text edits invalidate it automatically). startExistingSession reads
the marker and skips its own summarize when the persisted plan equals
what was summarized last, eliminating the redundant model call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:24:31 -07:00

2789 lines
93 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
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 { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
// Mock the API functions
const mockStartPlanning = vi.fn();
const mockStartPlanningStreaming = vi.fn();
const mockCreatePlanningDraft = vi.fn();
const mockConnectPlanningStream = vi.fn();
const mockRespondToPlanning = vi.fn();
const mockRetryPlanningSession = vi.fn();
const mockCancelPlanning = vi.fn();
const mockStopPlanningGeneration = vi.fn();
const mockUpdatePlanningSessionDraft = vi.fn();
const mockCreateTaskFromPlanning = vi.fn();
const mockStartPlanningBreakdown = vi.fn();
const mockCreateTasksFromPlanning = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockFetchModels = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
const mockUploadAttachment = vi.fn();
const mockDeleteAttachment = vi.fn();
const mockUpdateTask = vi.fn();
const mockPauseTask = vi.fn();
const mockUnpauseTask = vi.fn();
const mockFetchTaskDetail = vi.fn();
const mockRequestSpecRevision = vi.fn();
const mockApprovePlan = vi.fn();
const mockRejectPlan = vi.fn();
const mockRefineTask = vi.fn();
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({}),
}));
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
const mockTasks: Task[] = [
{
id: "FN-001",
description: "Existing task 1",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const mockModels = [
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
reasoning: true,
contextWindow: 200000,
},
{
provider: "google",
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
reasoning: true,
contextWindow: 1048576,
},
];
const mockQuestion: PlanningQuestion = {
id: "q-scope",
type: "single_select",
question: "What is the scope?",
description: "Choose the scope of this task",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
{ id: "large", label: "Large" },
],
};
const mockSummary: PlanningSummary = {
title: "Build authentication system",
description: "Implement user auth with login and signup",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Login page", "Signup page", "Auth API"],
};
const mockTaskDetail = {
id: "KB-999",
title: "Example task",
description: "Example description",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
attachments: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Task\n\nExample prompt",
paused: false,
} as TaskDetail;
class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
closed = false;
private listeners = new Map<string, Set<(event: MessageEvent) => void>>();
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(event: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(event) ?? new Set();
set.add(listener);
this.listeners.set(event, set);
}
removeEventListener(event: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(event);
if (!set) return;
set.delete(listener);
}
close(): void {
this.closed = true;
}
emit(event: string, data: unknown): void {
const listeners = this.listeners.get(event);
if (!listeners) return;
const message = { data: JSON.stringify(data) } as MessageEvent;
listeners.forEach((listener) => listener(message));
}
static reset(): void {
MockEventSource.instances = [];
}
}
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 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);
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: [],
});
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 view", () => {
it("renders the initial input view when open", () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByText("Planning Mode")).toBeDefined();
expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined();
expect(container.querySelector(".planning-modal-body")).not.toBeNull();
expect(container.querySelector(".planning-modal-body")?.classList.contains("modal-body")).toBe(false);
expect(container.querySelector(".planning-examples-label")?.textContent).toBe("Try an example:");
});
it("does not render when closed", () => {
render(
<PlanningModeModal
isOpen={false}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.queryByText("Planning Mode")).toBeNull();
});
it("hides send to background button in initial state", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.queryByLabelText("Send to background")).toBeNull();
});
it("enables start button when text is entered", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const startButton = screen.getByText("Start Planning");
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(true);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Test plan" } });
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false);
});
it("shows example chips", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByText(/Build a user authentication/)).toBeDefined();
});
it("renders planning model dropdown in initial view", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const modelTrigger = screen.getByRole("button", { name: "Planning Model" });
expect(modelTrigger).toBeDefined();
expect(screen.getByText("Using default")).toBeDefined();
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
});
it("updates planning model selection and badge", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeDefined();
});
it("passes selected planning model to startPlanningStreaming", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
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(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, {
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
}, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
});
});
it("renders planning depth controls with medium selected by default", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
expect(screen.getByRole("button", { name: "Small" })).toBeDefined();
expect(screen.getByRole("button", { name: "Medium" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "Large" })).toBeDefined();
expect(screen.getByLabelText("Questions")).toBeDefined();
});
it("updates selected depth and sends custom question count", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Large" }));
fireEvent.change(screen.getByLabelText("Questions"), { target: { value: "7" } });
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "large",
customQuestionCount: 7,
}, undefined);
});
});
it("calls startPlanningStreaming without model override when none selected", 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"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
});
});
it("auto-creates a draft after typing and reuses it when starting", 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 a detailed auth system plan" } });
await waitFor(() => {
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
});
expect(mockCreatePlanningDraft).toHaveBeenCalledWith(
"Build a detailed auth system plan",
undefined,
undefined,
);
// Sidebar shows the inputPayload-derived preview for draft rows so
// multiple drafts are distinguishable, not the placeholder title that
// createDraftSession returns. The text also appears in the textarea
// value, so scope the query to the sidebar item title element.
const sidebarItem = document.querySelector(".planning-sidebar-item-title");
expect(sidebarItem?.textContent).toBe("Build a detailed auth system plan");
fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan with extras" } });
await new Promise((resolve) => setTimeout(resolve, 350));
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
"Build a detailed auth system plan with extras",
undefined,
undefined,
{
planningDepth: "medium",
customQuestionCount: undefined,
},
"draft-123",
);
});
});
it("auto-starts planning when initialPlan prop is provided", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
initialPlan="Build a login system from new task dialog"
/>
);
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
}, { timeout: 2000 });
// Should transition to question view
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
});
it("sets initial plan text in textarea when initialPlan prop is provided", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
initialPlan="Pre-filled plan from new task"
/>
);
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
planningDepth: "medium",
customQuestionCount: undefined,
}, undefined);
}, { timeout: 2000 });
});
});
describe("modal height constraint regression", () => {
it("desktop planning modal max-height accounts for overlay padding", async () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
const modal = container.querySelector(".planning-modal");
expect(modal).toBeTruthy();
const { loadAllAppCss } = await import("../../test/cssFixture");
const css = loadAllAppCss();
const blockMatch = css.match(
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
);
expect(blockMatch).toBeTruthy();
const maxHeightValue = blockMatch![1].trim();
expect(maxHeightValue).toContain("calc(");
expect(maxHeightValue).toContain("100dvh");
expect(maxHeightValue).toContain("--overlay-padding-top");
});
});
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("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") 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", resumedSummary, undefined);
});
});
});
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();
});
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
});
expect(screen.getByTestId("conversation-history")).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();
expect(screen.getByText("Medium")).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("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.getByRole("combobox") 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).getByRole("combobox") 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");
});
});
describe("Modal smoke checks", () => {
it("renders TaskDetailModal with the standard detail body structure", () => {
const onMoveTask = vi.fn<(_: string, __: any) => Promise<Task>>().mockResolvedValue(mockTasks[0]);
const onDeleteTask = vi.fn<(_: string) => Promise<Task>>().mockResolvedValue(mockTasks[0]);
const onMergeTask = vi
.fn<(_: string) => Promise<MergeResult>>()
.mockResolvedValue({ merged: true, branch: "fusion/fn-999", task: mockTasks[0], worktreeRemoved: true, branchDeleted: true });
const { container } = render(
<TaskDetailModal
task={mockTaskDetail}
tasks={mockTasks}
onClose={mockOnClose}
onOpenDetail={vi.fn()}
onMoveTask={onMoveTask}
onDeleteTask={onDeleteTask}
onMergeTask={onMergeTask}
addToast={vi.fn()}
/>
);
expect(screen.getByText("Definition")).toBeDefined();
expect(container.querySelector(".detail-body")).not.toBeNull();
});
});
describe("Loading state", () => {
it("shows 'Generating next question...' text when loading without streaming content", async () => {
// Mock to delay the question response so we stay in loading state
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
// Don't call any handlers - stay in loading state
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"));
// Wait for loading state to appear
await waitFor(() => {
expect(container.querySelector(".planning-loading")).not.toBeNull();
});
// Should show "Generating next question..." not "Connecting..."
expect(screen.getByText("Generating next question...")).toBeDefined();
expect(screen.queryByText("Connecting...")).toBeNull();
});
it("shows thinking container even when streaming output is initially empty", async () => {
// Mock to delay the question response so we stay in loading state
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
// Don't call any handlers - stay in loading state
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"));
// Wait for loading state to appear
await waitFor(() => {
expect(container.querySelector(".planning-loading")).not.toBeNull();
});
// Thinking container should be visible even without streaming content
expect(container.querySelector(".planning-thinking-container")).not.toBeNull();
// showThinking defaults to true, so button shows "Hide thinking"
expect(screen.getByText("Hide thinking")).toBeDefined();
});
it("shows 'AI is thinking...' text and renders streaming content when it arrives", async () => {
let streamHandlers: any = null;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
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"));
// Wait for loading state to appear
await waitFor(() => {
expect(container.querySelector(".planning-loading")).not.toBeNull();
});
// Initially shows "Generating next question..."
expect(screen.getByText("Generating next question...")).toBeDefined();
// Simulate streaming content arriving
await waitFor(() => {
streamHandlers.onThinking?.("Analyzing requirements...");
});
// Now should show "AI is thinking..."
await waitFor(() => {
expect(screen.getByText("AI is thinking...")).toBeDefined();
});
// The streaming content should be visible (showThinking defaults to true)
await waitFor(() => {
expect(screen.getByText("Analyzing requirements...")).toBeDefined();
});
// Click "Hide thinking" to hide the output
fireEvent.click(screen.getByText("Hide thinking"));
// The output should now be hidden
expect(screen.queryByText("Analyzing requirements...")).toBeNull();
});
it("shows loading state with appropriate text after submitting a response", async () => {
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
description: "Describe the requirements",
};
let streamHandlers: any = null;
mockConnectPlanningStream.mockImplementation((_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 () => {
// Simulate server broadcasting second question via the existing SSE connection
setTimeout(() => {
if (streamHandlers) {
streamHandlers.onQuestion?.(secondQuestion);
}
}, 50);
return { sessionId: "session-123", currentQuestion: null, summary: null };
});
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"));
// Wait for first question
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Answer the first question
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByText("Continue"));
// Verify loading state appears with correct message
await waitFor(() => {
expect(container.querySelector(".planning-loading")).not.toBeNull();
expect(screen.getByText("Generating next question...")).toBeDefined();
});
// Verify thinking container is visible during loading
expect(container.querySelector(".planning-thinking-container")).not.toBeNull();
// Wait for second question to appear
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
}, { timeout: 3000 });
});
});
describe("Modal close behavior", () => {
it("no confirmation shown when no progress made (initial state)", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
// Click X button while still in initial state (no planning started)
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes active question session WITHOUT abandoning the server session", 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();
});
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing the modal should leave the server session intact so it stays in the sidebar list
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes summary view WITHOUT abandoning the server session", 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}
/>
);
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("Planning Complete!")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Completed sessions remain available to resume; closing must not cancel them
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes via overlay WITHOUT abandoning the server session", async () => {
const { container } = 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 overlay = container.querySelector(".modal-overlay");
expect(overlay).not.toBeNull();
// Simulate a real overlay click — both mousedown and click must originate
// on the overlay, otherwise the dismissal guard suppresses close.
fireEvent.mouseDown(overlay!);
fireEvent.click(overlay!);
expect(mockConfirm).not.toHaveBeenCalled();
// Sessions persist in the sidebar; overlay click should not cancel
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes during loading state WITHOUT abandoning the server session", async () => {
mockConnectPlanningStream.mockImplementationOnce(() => ({
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();
});
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Loading state means the session is still being generated server-side; preserve it
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("close (X) drops the local stream but preserves the server session", async () => {
// The "Send to background" button was removed — closing the modal now
// has the same semantics: tear down the SSE stream, keep the persisted
// session alive so the user can reopen and resume it.
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce(() => ({
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.getByText("Generating next question...")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Close"));
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("disconnects the SSE stream on close (but keeps the server session)", async () => {
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce(() => ({
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.getByText("Generating next question...")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Close"));
expect(closeSpy).toHaveBeenCalledTimes(1);
// The local SSE stream closes on modal close, but the server session is preserved
// for later resume from the sidebar list.
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
});
describe("Model favorites persistence", () => {
it("persists provider favorite toggle to global settings", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
// When provider is favorited, the optgroup header shows "Remove" button
const removeButton = within(portal).queryByRole("button", { name: "Remove anthropic from favorites" });
expect(removeButton).not.toBeNull();
fireEvent.click(removeButton!);
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: [],
favoriteModels: [],
});
});
it("persists model favorite toggle to global settings", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
});
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
// When model is favorited, it appears as a pinned row with "Remove" button
// There may be duplicates (in pinned row + provider group), use first one
const removeButtons = within(portal).queryAllByRole("button", { name: "Remove Claude Sonnet 4.5 from favorites" });
expect(removeButtons.length).toBeGreaterThan(0);
fireEvent.click(removeButtons[0]);
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: [],
favoriteModels: [],
});
});
it("adds provider to favorites", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
});
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
const addButton = within(portal).getByRole("button", { name: "Add anthropic to favorites" });
fireEvent.click(addButton);
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
});
it("rolls back local favorite state when updateGlobalSettings fails", async () => {
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
vi.mocked(api.updateGlobalSettings).mockRejectedValueOnce(new Error("Network error"));
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
const removeButton = within(portal).getByRole("button", { name: "Remove anthropic from favorites" });
fireEvent.click(removeButton);
// Wait for the rejected promise to settle
await waitFor(() => {
expect(api.updateGlobalSettings).toHaveBeenCalled();
});
// After rollback, the provider should still show as favorited (★ button with "Remove" aria-label)
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
});
});
});
describe("getSessionTabId", () => {
it("creates and persists a per-tab id in sessionStorage", () => {
window.sessionStorage.clear();
const first = getSessionTabId();
const second = getSessionTabId();
expect(first).toBeTruthy();
expect(second).toBe(first);
expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first);
});
});
describe("useSessionLock", () => {
beforeEach(() => {
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
});
it("acquires on mount and releases on unmount", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const { unmount } = renderHook(() => useSessionLock("session-1"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
unmount();
await waitFor(() => {
expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
});
});
it("exposes locked state and allows taking control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
const { result } = renderHook(() => useSessionLock("session-2"));
await waitFor(() => {
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
});
await act(async () => {
await result.current.takeControl();
});
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self");
expect(result.current.isLockedByOther).toBe(false);
expect(result.current.currentHolder).toBeNull();
});
it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
const sendBeaconSpy = vi.fn(() => true);
vi.stubGlobal("navigator", {
...window.navigator,
sendBeacon: sendBeaconSpy,
} as Navigator);
const { result } = renderHook(() => useSessionLock("session-3"));
await waitFor(() => {
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self");
});
const source = MockEventSource.instances[0];
expect(source).toBeDefined();
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-other",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(true);
expect(result.current.currentHolder).toBe("tab-other");
act(() => {
source?.emit("ai_session:updated", {
id: "session-3",
type: "planning",
status: "awaiting_input",
title: "Session",
projectId: null,
lockedByTab: "tab-self",
updatedAt: new Date().toISOString(),
});
});
expect(result.current.isLockedByOther).toBe(false);
act(() => {
window.dispatchEvent(new Event("beforeunload"));
});
expect(sendBeaconSpy).toHaveBeenCalledWith(
"/api/ai-sessions/session-3/lock/beacon?tabId=tab-self",
);
});
});