Files
fusion/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx
gsxdsm cdf67c1d98 fix(dashboard): stop Planning Mode retry loop, make AI sessions multi-tab (#2101)
## Problem

Reported: planning gets stuck in a cycle of retrying and regenerating
after a response was already supplied.

After the user answers a planning question, `submitResponse` pushed the
answer to history but left `session.currentQuestion` pointing at the
just-answered question for the whole next generation. The planning SSE
route's catch-up path re-emits `currentQuestion` to every fresh
connection — and each FN-7946 auto-retry (#2073) opens a fresh
connection. So after any generation error:

1. Auto-retry connects a fresh stream → the server re-emits the
**already-answered** question.
2. The client treats any question event as progress: it **resets the
3-attempt auto-retry budget** and re-shows the answered question.
3. The retry regenerates; if it errors again the cycle repeats with a
fresh budget — an unbounded retry/regenerate loop. Re-answering the
stale question also 409-collided with the in-flight generation, feeding
the same loop.

## Fix

Invariant: `currentQuestion` is only set while the session is genuinely
awaiting user input.

- `submitResponse` clears it the moment an answer is accepted (normal
turns and the deepening checkpoint), while preserving the legacy 200
respond contract on generation failure (the modal ignores the body and
lets the SSE error drive recovery).
- `retrySession` scrubs stale questions persisted by pre-fix builds
before regenerating.
- `buildSessionFromRow` only restores a question when the persisted row
is `awaiting_input`.
- `didSubmitSameAnswer` now compares against the last history entry so
the duplicate-submit 409 message survives.
- Agent onboarding gets the same fix (its SSE route also re-emits
`currentQuestion` on connect); retry now asks the next question instead
of re-asking the answered one.

Surface enumeration: mission and milestone interviews keep questions the
same way but their SSE routes never re-emit on connect, and the
auto-retry budget machinery is Planning-Mode-only — planning +
onboarding were the two affected surfaces.

## Symptom Verification

- **Original symptom:** after answering a question, Planning Mode loops
between "Retrying…" and regenerating, re-showing the already-answered
question, with the auto-retry budget never exhausting.
- **Exact reproduction:** answer a question, have the next generation
fail (stuck watchdog/provider error), let the client auto-retry open a
fresh SSE connection.
- **Assertion it is gone:** new regression suite
`planning-answered-question-reemit.test.ts` asserts `currentQuestion` is
cleared mid-generation, on generation failure, on retry, and on restore
from non-`awaiting_input` rows — so the SSE catch-up path has nothing
stale to re-emit. All 5 tests fail against pre-fix code and pass with
the fix; an onboarding regression test covers the sibling surface.

## Verification

- New regression tests: 5/5 fail on pre-fix code, pass with the fix
(plus 1 onboarding test).
- Existing suites: 137 planning server tests pass (3 failures in
`routes-planning.test.ts` fail identically without this change —
pre-existing on the branch); all 69 `PlanningModeModal.planning-flow`
client tests pass; `tsc --noEmit` clean; `pnpm check:changesets` passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Made Planning Mode (and related planning controls) lock-free and
multi-tab—no more take-over/active-in-another-tab lock overlays.

* **Bug Fixes**
* Fixed Planning Mode retry/generation flows where already-answered
questions could reappear.
* Ensured answered questions clear immediately and aren’t re-emitted
during session recovery/SSE catch-up.
* Improved session restoration and preserved legacy recovery behavior
when generation fails after an answer.

* **Tests**
* Added regression coverage for the answered-question invariant and
updated existing tests to reflect lock-free behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Follow-up: Planning Mode is now multi-tab via DB state (lock-free)

Second commit removes all cross-tab coordination from planning — the
persisted session row is the single source of truth and multiple tabs
can read and interact with the same session:

- **Server:** `/planning/*` routes no longer run `checkSessionLock` or
parse `tabId`; a stale `tabId` from an older client is ignored instead
of 409'd. Subtask/mission interview routes keep their existing lock
behavior.
- **Client:** `PlanningModeModal` drops `useSessionLock`, the
`useAiSessionSync` BroadcastChannel broadcasts,
`sessionTabId`/`lockSessionId` state, and the "Take Control" overlay.
Tabs stay current via the per-session SSE stream plus the global
`ai_session:updated` events `useBackgroundSessions` already consumes;
concurrent writes resolve via the server's generation-in-progress guard
(409).
- **API client:** planning functions lose their `tabId` params.
- **Fix uncovered by the refactor:** the 8s stuck-poll now resolves the
session id inside each tick — the removed lock state was what previously
re-armed the poll after Start Planning resolved the session id.
- Also fixes a pre-existing PG-cutover break in
`planning-generation-cancellation.test.ts` (`getSession` is async).

Verification: 144 client planning tests and 137 server planning tests
pass (the 3 remaining `routes-planning.test.ts` failures are
pre-existing on the branch and fail identically without these changes);
`tsc --noEmit` and eslint clean on changed files; `pnpm
check:changesets` passes. Lock-conflict route tests were rewritten to
assert lock-free semantics, plus a new modal test proving a session
stays fully interactive with no lock acquisition even when another tab
is active.


---

## Follow-up 2: the per-tab session lock is gone entirely

Third commit extends the multi-tab model from planning to **every** AI
interview surface (planning, subtask breakdown, mission interview,
milestone/slice interview) and deletes the lock machinery root and
branch.

**Server**
- Deleted the `/ai-sessions/:id/lock`, `/lock/force`, and `/lock/beacon`
routes.
- Dropped `checkSessionLock` from every
planning/subtask/mission/milestone route (both copies — `routes.ts` and
`mission-routes.ts`). A `tabId` from an older client is ignored, never
409'd; all `tabId` body parsing is gone.
- Dropped `acquireLock` / `releaseLock` / `forceAcquireLock` /
`getLockHolder` / `releaseStaleLocks` from `AiSessionStore`, plus the
`@fusion/core` async helpers (`acquireAiSessionLock` et al) and core's
re-exports.
- Removed `lockedByTab`/`lockedAt` from
`AiSessionRow`/`AiSessionSummary`, the upsert SQL, and all four session
producers.

**Client**
- Deleted `useSessionLock` and the now-orphaned `getSessionTabId` util.
- Removed the Take Control overlay, the "active in another tab" banners,
and `BackgroundTasksIndicator`'s active-elsewhere gate (the confirm
prompt and lock badge — sessions now just open).
- Reduced `useAiSessionSync` to what its own comments already called it
— a low-latency *status* supplement to SSE: no `activeTabMap`,
`broadcastLock/Unlock/Heartbeat`, `owningTabId`, `tab:*` messages, or
stale-heartbeat sweep.
- Dropped `tabId` from every session API client function; removed the
lock CSS.

**Deliberately kept: the two DB columns.** `ai_sessions.locked_by_tab` /
`locked_at` remain as dead, always-NULL columns with a deprecation note.
Dropping them is an irreversible migration, and released binaries still
name those columns explicitly in their upsert — an older install pointed
at the same database would fail every session write. They can be dropped
once no such binary can reach it. No code reads or writes them.

**Verification**: 397 client tests and 137 server planning tests pass
(the same 3 `routes-planning.test.ts` failures are pre-existing —
verified identical on a clean stash); `tsc --noEmit` clean for
`@fusion/core` and `@fusion/dashboard`; eslint clean on all changed
files; the 30 PG `schema-applier` tests pass (they exercise the retained
columns); `pnpm check:changesets` passes. The lock-conflict route tests
and both modal lock tests were rewritten to assert the inverse: routes
and modals stay fully interactive while another tab "holds" a lock, and
the lock API is never called.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:47:53 -07:00

927 lines
29 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MilestoneSliceInterviewModal } from "../MilestoneSliceInterviewModal";
const mockStartMilestoneInterview = vi.fn();
const mockStartSliceInterview = vi.fn();
const mockRespondToMilestoneInterview = vi.fn();
const mockRespondToSliceInterview = vi.fn();
const mockApplyMilestoneInterview = vi.fn();
const mockApplySliceInterview = vi.fn();
const mockSkipMilestoneInterview = vi.fn();
const mockSkipSliceInterview = vi.fn();
const mockConnectMilestoneInterviewStream = vi.fn();
const mockConnectSliceInterviewStream = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
vi.mock("../../api", () => ({
startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args),
startSliceInterview: (...args: any[]) => mockStartSliceInterview(...args),
respondToMilestoneInterview: (...args: any[]) => mockRespondToMilestoneInterview(...args),
respondToSliceInterview: (...args: any[]) => mockRespondToSliceInterview(...args),
applyMilestoneInterview: (...args: any[]) => mockApplyMilestoneInterview(...args),
applySliceInterview: (...args: any[]) => mockApplySliceInterview(...args),
skipMilestoneInterview: (...args: any[]) => mockSkipMilestoneInterview(...args),
skipSliceInterview: (...args: any[]) => mockSkipSliceInterview(...args),
connectMilestoneInterviewStream: (...args: any[]) => mockConnectMilestoneInterviewStream(...args),
connectSliceInterviewStream: (...args: any[]) => mockConnectSliceInterviewStream(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
}));
vi.mock("../../hooks/useAiSessionSync", () => ({
useAiSessionSync: vi.fn(() => ({
broadcastUpdate: vi.fn(),
broadcastCompleted: vi.fn(),
})),
}));
const mockUseMobileKeyboard = vi.fn();
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => "mobile",
isMobileViewport: () => true,
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", () => ({
X: () => <span data-testid="x-icon">X</span>,
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className}>Loader</span>,
CheckCircle: () => <span data-testid="check-circle-icon">CheckCircle</span>,
ArrowRight: () => <span data-testid="arrow-right-icon">ArrowRight</span>,
Sparkles: () => <span data-testid="sparkles-icon">Sparkles</span>,
ChevronRight: () => <span data-testid="chevron-right-icon">ChevronRight</span>,
ChevronDown: () => <span data-testid="chevron-down-icon">ChevronDown</span>,
Minimize2: () => <span data-testid="minimize-icon">Minimize2</span>,
}));
const SAMPLE_QUESTION = {
id: "scope",
type: "single_select" as const,
question: "What is the target scope?",
description: "Pick the size for this feature.",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full" },
],
};
describe("MilestoneSliceInterviewModal", () => {
let streamHandlers: any;
beforeEach(() => {
mockStartMilestoneInterview.mockReset();
mockStartSliceInterview.mockReset();
mockRespondToMilestoneInterview.mockReset();
mockRespondToSliceInterview.mockReset();
mockApplyMilestoneInterview.mockReset();
mockApplySliceInterview.mockReset();
mockSkipMilestoneInterview.mockReset();
mockSkipSliceInterview.mockReset();
mockConnectMilestoneInterviewStream.mockReset();
mockConnectSliceInterviewStream.mockReset();
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockFetchAiSession.mockReset();
mockParseConversationHistory.mockReset();
mockParseConversationHistory.mockReturnValue([]);
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
});
// Setup stream handlers capture
mockConnectMilestoneInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
mockConnectSliceInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
});
describe("initial view", () => {
it("renders with correct title for milestone", () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
expect(screen.getByText("Plan Milestone: Test Milestone")).toBeDefined();
});
it("renders with correct title for slice", () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>
);
expect(screen.getByText("Plan Slice: Test Slice")).toBeDefined();
});
it("shows three action buttons: Start Interview, Use Mission Context, Cancel", () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
expect(screen.getByText("Start Interview")).toBeDefined();
expect(screen.getByText("Use Mission Context")).toBeDefined();
expect(screen.getByText("Cancel")).toBeDefined();
});
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={onClose}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Cancel"));
expect(onClose).toHaveBeenCalled();
});
});
describe("Start Interview button", () => {
it("calls startMilestoneInterview for targetType=milestone", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(mockStartMilestoneInterview).toHaveBeenCalledWith("MS-001", "test-project");
});
});
it("calls startSliceInterview for targetType=slice", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(mockStartSliceInterview).toHaveBeenCalledWith("SL-001", "test-project");
});
});
it("applies keyboard CSS variables to planning modal when keyboard is open", () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="milestone-1"
targetTitle="Milestone 1"
/>,
);
const modal = container.querySelector(".planning-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
});
it("shows loading state after clicking Start Interview", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
mockConnectMilestoneInterviewStream.mockReturnValue({
close: vi.fn(),
isConnected: vi.fn(() => false),
});
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText("Preparing next question...")).toBeDefined();
});
});
});
describe("Use Mission Context button", () => {
it("calls skipMilestoneInterview and onApplied for targetType=milestone", async () => {
mockSkipMilestoneInterview.mockResolvedValue({ id: "MS-001", title: "Test Milestone" });
const onApplied = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={onApplied}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Use Mission Context"));
await waitFor(() => {
expect(mockSkipMilestoneInterview).toHaveBeenCalledWith("MS-001", "test-project");
expect(onApplied).toHaveBeenCalled();
});
});
it("calls skipSliceInterview and onApplied for targetType=slice", async () => {
mockSkipSliceInterview.mockResolvedValue({ id: "SL-001", title: "Test Slice" });
const onApplied = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={onApplied}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Use Mission Context"));
await waitFor(() => {
expect(mockSkipSliceInterview).toHaveBeenCalledWith("SL-001", "test-project");
expect(onApplied).toHaveBeenCalled();
});
});
it("does not call any interview API when Cancel is clicked", () => {
const onClose = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={onClose}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Cancel"));
expect(mockStartMilestoneInterview).not.toHaveBeenCalled();
expect(mockSkipMilestoneInterview).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
describe("question flow", () => {
it("shows question after interview starts and AI responds", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
// Simulate AI response with question
await waitFor(() => {
// Loading state should appear first
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
// Simulate question event from stream
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeDefined();
expect(screen.getByText("Pick the size for this feature.")).toBeDefined();
});
});
});
describe("summary and apply", () => {
it("shows summary view after interview completes", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
// Wait for loading state
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
// Simulate summary event
act(() => {
if (streamHandlers?.onSummary) {
streamHandlers.onSummary({
description: "Refined description",
});
}
});
// Give React time to update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
// Summary should show Refined Scope header
expect(screen.getByText("Refined Scope")).toBeDefined();
});
});
describe("comment input", () => {
it("submits trimmed Other-only answers for single-select milestone questions", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await screen.findByText("What is the target scope?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Split this differently " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
{ _other: "Split this differently" },
"test-project",
);
});
});
it("renders Other for single-select milestone questions with no provided options", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "open_scope",
type: "single_select",
question: "What is the target scope?",
});
});
await screen.findByText("What is the target scope?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Define a custom scope " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
{ _other: "Define a custom scope" },
"test-project",
);
});
});
it("clears stale Other text when unchecking Other in multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: "Keep this manual" },
});
expect(continueButton).toBeEnabled();
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(screen.queryByTestId("planning-other-input")).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ priorities: ["speed"] },
"test-project",
);
});
});
it("submits Other-only answers for multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Reframe around dependencies " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ _other: "Reframe around dependencies" },
"test-project",
);
});
});
it("renders Other for multi-select slice questions with no provided options", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "open_priorities",
type: "multi_select",
question: "Which priorities matter?",
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Ask customers first " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ _other: "Ask customers first" },
"test-project",
);
});
});
it("combines provided options with Other text for multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Preserve manual review " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ priorities: ["speed"], _other: "Preserve manual review" },
"test-project",
);
});
});
it("shows comment textarea and submits _comment in milestone interview", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await screen.findByText("What is the target scope?");
expect(screen.getByPlaceholderText("Add any extra context or direction...")).toBeDefined();
fireEvent.click(screen.getByText("MVP"));
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
target: { value: "Keep this aligned with mission MVP" },
});
fireEvent.click(screen.getByRole("button", { name: /Continue/ }));
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
expect.objectContaining({ scope: "mvp", _comment: "Keep this aligned with mission MVP" }),
"test-project",
);
});
});
});
describe("resume session rehydration", () => {
const mockSessionAwaitingInput = {
id: "session-resume-123",
type: "milestone_interview" as const,
status: "awaiting_input" as const,
title: "Plan milestone scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
inputPayload: JSON.stringify({
targetType: "milestone",
targetId: "MS-001",
targetTitle: "Test Milestone",
missionContext: "Test Mission",
}),
conversationHistory: JSON.stringify([
{ question: { id: "q1", type: "text", question: "What is the scope?" }, response: { q1: "MVP" } } ]),
currentQuestion: JSON.stringify(SAMPLE_QUESTION),
result: null,
thinkingOutput: "",
error: null,
createdAt: new Date().toISOString(),
};
const mockSessionGenerating = {
...mockSessionAwaitingInput,
id: "session-resume-456",
status: "generating" as const,
currentQuestion: null,
thinkingOutput: "Analyzing requirements...",
};
const mockSessionError = {
...mockSessionAwaitingInput,
id: "session-resume-789",
status: "error" as const,
currentQuestion: null,
result: null,
error: "AI service unavailable",
};
it("restores awaiting_input session with question when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionAwaitingInput);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-123"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-123");
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeDefined();
expect(screen.getByText("Pick the size for this feature.")).toBeDefined();
});
});
it("reconnects to stream for generating session when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionGenerating);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-456"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-456");
expect(mockConnectMilestoneInterviewStream).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/AI is thinking/)).toBeDefined();
});
});
it("shows error state for error session when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionError);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-789"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-789");
});
await waitFor(() => {
expect(screen.getByText("AI service unavailable")).toBeDefined();
});
});
it("does not resume when resumeSessionId is not provided", async () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
expect(mockFetchAiSession).not.toHaveBeenCalled();
expect(screen.getByText("Start Interview")).toBeDefined();
});
});
});