FN-9117: Preserve Planning Mode answers across session hydration

Keep visible Planning Mode controls bound to the current turn during asynchronous refreshes.

- preserve active question and plan-review workspaces during same-session hydration
- submit from live session state and retain dirty answers across response identity changes
- add desktop and mobile regression coverage for deferred hydration and stale Stop polling
- document the ownership race and add a patch changeset

Files changed:
 .changeset/fn-9117-planning-turn-ownership.md      |   7 +
 .../suite-only-flakes-observed-register.md         |  24 ++++
 .../dashboard/app/components/PlanningModeModal.tsx | 141 ++++++++++++---------
 .../PlanningModeModal.planning-flow.test.tsx       | 132 +++++++++++++++++++
 .../PlanningModeModal.ui-interactions.test.tsx     |  20 +++
 5 files changed, 265 insertions(+), 59 deletions(-)

Fusion-Task-Id: FN-9117

Fusion-Task-Lineage: 18d63f63-b532-49eb-a6d1-241e31c5b2c7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-16 02:26:42 -07:00
parent 3272affbb3
commit 111c6c96cc
5 changed files with 265 additions and 59 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve typed Planning Mode answers during late session hydration.
category: fix
dev: Binds visible question submission to the live planning turn and preserves dirty answers during same-session refresh.

View File

@@ -187,3 +187,27 @@ The flake is structurally removed rather than stabilized: every hydration/recove
**Resolved 2026-08-16 (FN-9120): both a timing-sensitive test assertion and a product race.** The original third phase synchronously asserted after `userEvent.type` while the loaded lane still rendered populated rows, even though its once queue had not shifted. Independently, the production effect had no cleanup or request identity, so a close/reopen, project change, or unmount could let a stale fetch write roster/loading/error state; initial `loadingAgents=false` also exposed terminal empty copy before the first effect. **Resolved 2026-08-16 (FN-9120): both a timing-sensitive test assertion and a product race.** The original third phase synchronously asserted after `userEvent.type` while the loaded lane still rendered populated rows, even though its once queue had not shifted. Independently, the production effect had no cleanup or request identity, so a close/reopen, project change, or unmount could let a stale fetch write roster/loading/error state; initial `loadingAgents=false` also exposed terminal empty copy before the first effect.
The component now owns an explicit idle/loading/loaded/failed phase and fences each request with an epoch plus cleanup. A current successful reload removes selected IDs absent from its roster. The test uses controlled deferred promises in a single persistently-mounted modal, proves close/reopen/project ordering, failure and unmount fencing, duplicate-name/selection reconciliation, and desktop/mobile empty-state copy invariants without retries, sleeps, waits around the old assertion, or mock re-pinning. The component now owns an explicit idle/loading/loaded/failed phase and fences each request with an epoch plus cleanup. A current successful reload removes selected IDs absent from its roster. The test uses controlled deferred promises in a single persistently-mounted modal, proves close/reopen/project ordering, failure and unmount fencing, duplicate-name/selection reconciliation, and desktop/mobile empty-state copy invariants without retries, sleeps, waits around the old assertion, or mock re-pinning.
## 10. Planning Mode loaded-turn affordance ownership
- **Files:** `packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx`, `PlanningModeModal.ui-interactions.test.tsx`
- **Exact cases:** `opens Plan preview without submitting and preserves the current mobile answer on return`; `can restart initial planning after stopping its first generation`; `can refine a stopped initial plan into the first question`; both desktop/mobile rows of `keeps five substantive choices and one Other usable on %s`; `submits an answer after deferred same-session hydration on %s`; and FN-9117's `keeps post-Stop plan review when a pre-Stop loading poll resolves on %s`.
- **Observed tree/SHA:** original reports at `9a9e591b72`; completed remediation tree `603373b93a`.
**Resolved 2026-08-16 (FN-9117): Product ownership race, not a timeout defect.** `QuestionForm` rendered from `workspaceQuestion`, while submit formerly branched on a closed-over `view`; a late hydration could therefore drop an enabled Next action. It also restored every new `initialResponse` object identity, overwriting a dirty same-question draft and disabling the mobile Next-question path. FN-9117 binds submit to the live session/question state and preserves a dirty same-question draft.
The Stop audit also confirmed the recovery-poll ownership hazard: Stop invalidates loading state then can restore the same session id for a question or summary terminal view. FN-9116's load-and-turn fence now rejects a poll started before that boundary. FN-9117 adds real-modal desktop and mobile deferred-poll coverage: fake timer time starts the 8-second poll, a deferred stale durable question resolves after Stop, and post-Stop plan review remains intact. The pre-FN-9116 source had effect-cleanup cancellation once terminal React state committed; the epoch fence closes the earlier render/cleanup interval structurally. No timeout, retry, widened wait, sleep, weakened assertion, or quarantine was used.
This completes the two Stop reports rather than deferring them as unreproduced. A same-session `ai_session:updated` rehydrate was the remaining transient-unmount path: `loadSession` cleared `workspaceQuestion` before its fetch resolved, unmounting `QuestionForm` and discarding the dirty answer. It now preserves an active question/plan-review workspace only for a refresh of that same session; a different session still enters the neutral loader. The real-modal deferred-hydration test uses per-character `userEvent.type` on desktop and the mobile Other choice, then asserts the exact `respondToPlanning` payload after the controlled commit.
It is the companion to entries 4, 5, and 8: FN-8936 fixed detached test-node handoff; FN-9116 fences duplicate-response and recovery writers; FN-9117 ensures visible question controls use the live turn and retain operator drafts.
| verification | result |
|---|---|
| targeted planning-flow + ui-interactions ×3 | **passed** (84 planning-flow tests, 20 UI-interaction tests) |
| all `PlanningModeModal.*` sibling suites | **passed** |
| `test:quality:app:backfill` aggregate attempt | shards 1–3 passed; initial 300s bound ended during shard 4, which passed when run directly |
| `test:quality:app:backfill` aggregate attempts 2–3 | blocked by repeated unrelated `CreateRoomModal` search-state failures; filed as FN-9121 with full logs `/tmp/fn-9117-backfill-run-{2,3}.log` |
| `pnpm lint`, `pnpm verify:fast`, `pnpm build` | **passed** |
No UI surface changed; this was a state-ownership and regression-coverage repair. The existing patch changeset remains applicable because Planning Mode behavior is user-visible.

View File

@@ -2047,6 +2047,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const loadSession = useCallback( const loadSession = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
const loadEpoch = ++planningSessionLoadEpochRef.current; const loadEpoch = ++planningSessionLoadEpochRef.current;
/*
FNXC:PlanningMode 2026-08-16-08:54:
An idle-session SSE refresh rehydrates the same server session while its question remains
actionable. Do not transiently clear that workspace: unmounting QuestionForm discards a
typed or selected local answer before its enabled Next action can submit it. A different
session still takes the neutral loader so its prior turn never remains visible.
*/
const preservesActiveWorkspace = currentSessionIdRef.current === sessionId
&& (viewRef.current.type === "question" || viewRef.current.type === "plan_review");
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
currentSessionIdRef.current = sessionId; currentSessionIdRef.current = sessionId;
@@ -2060,23 +2069,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
review with a working View task button. Clear it at load start; the complete branch review with a working View task button. Clear it at load start; the complete branch
restores it for the session actually being loaded. restores it for the session actually being loaded.
*/ */
setLinkedTaskId(null); if (!preservesActiveWorkspace) {
setLinkedTask(null); setLinkedTaskId(null);
setStreamingOutput(""); setLinkedTask(null);
setResponseHistory([]); setStreamingOutput("");
setConversationHistory([]); setResponseHistory([]);
setEditedSummary(null); setConversationHistory([]);
setRunningSummary(null); setEditedSummary(null);
setWorkspaceQuestion(null); setRunningSummary(null);
setLoadedSessionTitle(null); setWorkspaceQuestion(null);
setIsRetrying(false); setLoadedSessionTitle(null);
setIsRefiningSummary(false); setIsRetrying(false);
refineSummaryInFlightRef.current = false; setIsRefiningSummary(false);
setGenerationStartTime(null); refineSummaryInFlightRef.current = false;
// FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader, setGenerationStartTime(null);
// not the generation pane — only a fetched status of "generating" enters `loading` below. // FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader,
viewRef.current = { type: "session_loading" }; // not the generation pane — only a fetched status of "generating" enters `loading` below.
setView({ type: "session_loading" }); viewRef.current = { type: "session_loading" };
setView({ type: "session_loading" });
}
try { try {
const session = await fetchAiSession(sessionId); const session = await fetchAiSession(sessionId);
@@ -2827,44 +2838,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleSubmitResponse = useCallback( const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => { async (responses: QuestionResponse) => {
if (view.type !== "question") return; const sessionId = currentSessionIdRef.current;
const activeQuestion = workspaceQuestion;
const { session } = view; if (!sessionId || !activeQuestion) {
const sessionId = session.sessionId; setError(t("planning.noActiveQuestion", "No active question is available. Wait for the interview to resume."));
const activeQuestion = session.currentQuestion;
if (!activeQuestion) {
/*
FNXC:PlanningQuestionRegeneration 2026-07-23-21:40:
Submitting with no active question is no longer a dead-end "No active question in
session" error. The server regenerates a fresh interview question from the accumulated
context, so forward the input and enter the loading state; the SSE question event (or
the HTTP payload for restored sessions without a live stream) restores the view. No
optimistic history entry is recorded because there is no question to pair it with.
*/
setError(null);
resetPlanningAutoRetryBudget();
setGenerationActivity("question");
setGenerationStartTime(Date.now());
setView({ type: "loading" });
setStreamingOutput("");
currentSessionIdRef.current = sessionId;
if (!streamConnectionRef.current?.isConnected()) {
connectToPlanningStream(sessionId);
}
try {
const response = await respondToPlanning(sessionId, responses, projectId);
const responseQuestion = "type" in response ? response.data : response.currentQuestion;
if (responseQuestion) {
const nextQuestion = normalizeQuestionOptions(responseQuestion);
setWorkspaceQuestion(nextQuestion);
setView({ type: "question", session: { sessionId, currentQuestion: nextQuestion, summary: runningSummaryRef.current } });
}
} catch (err) {
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response"));
setView({ type: "question", session: { sessionId, currentQuestion: null, summary: runningSummaryRef.current } });
}
return; return;
} }
/*
FNXC:PlanningMode 2026-08-16-06:28:
The form is rendered from workspaceQuestion and remains visible beneath the loading overlay.
Submit must use that live question and session ref, not a render-time view snapshot: a late
hydration may change view between an enabled Next render and its user event, and must never
silently discard that answer.
*/
const session = { sessionId, currentQuestion: activeQuestion, summary: runningSummaryRef.current };
setError(null); setError(null);
const responseTurnEpoch = ++planningTurnEpochRef.current; const responseTurnEpoch = ++planningTurnEpochRef.current;
@@ -3000,7 +2987,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } }); setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } });
} }
}, },
[connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, t, view] [connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, t, workspaceQuestion]
); );
const handleStopGeneration = useCallback(async () => { const handleStopGeneration = useCallback(async () => {
@@ -4433,6 +4420,11 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
const [commentValue, setCommentValue] = useState(""); const [commentValue, setCommentValue] = useState("");
const [otherValue, setOtherValue] = useState(""); const [otherValue, setOtherValue] = useState("");
const [isOtherSelected, setIsOtherSelected] = useState(false); const [isOtherSelected, setIsOtherSelected] = useState(false);
const dirtyResponseRef = useRef(false);
const restoredQuestionIdRef = useRef<string | null>(null);
const markResponseDirty = useCallback(() => {
dirtyResponseRef.current = true;
}, []);
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({ const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
value: textValue, value: textValue,
minHeight: 120, minHeight: 120,
@@ -4507,9 +4499,18 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
// Restore a selected history answer so editing is a direct, non-destructive operation. // Restore a selected history answer so editing is a direct, non-destructive operation.
useEffect(() => { useEffect(() => {
if (restoredQuestionIdRef.current === question.id && dirtyResponseRef.current) return;
const prior = initialResponse ?? {}; const prior = initialResponse ?? {};
const other = typeof prior[PLANNING_OTHER_RESPONSE_KEY] === "string" ? prior[PLANNING_OTHER_RESPONSE_KEY] : ""; const other = typeof prior[PLANNING_OTHER_RESPONSE_KEY] === "string" ? prior[PLANNING_OTHER_RESPONSE_KEY] : "";
const text = prior[question.id]; const text = prior[question.id];
/*
FNXC:PlanningMode 2026-08-16-06:28:
Hydration can replace the parent response object after a user starts answering the same
question. Restore durable data only until that local turn is dirty; object identity churn
must not erase an enabled form's unsubmitted answer or disable Next question.
*/
dirtyResponseRef.current = false;
restoredQuestionIdRef.current = question.id;
setResponse(prior); setResponse(prior);
setTextValue(typeof text === "string" ? text : ""); setTextValue(typeof text === "string" ? text : "");
setCommentValue(typeof prior._comment === "string" ? prior._comment : ""); setCommentValue(typeof prior._comment === "string" ? prior._comment : "");
@@ -4571,7 +4572,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
className="planning-textarea" className="planning-textarea"
placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")} placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")}
value={textValue} value={textValue}
onChange={(e) => setTextValue(e.target.value)} onChange={(e) => {
markResponseDirty();
setTextValue(e.target.value);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) { if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
e.preventDefault(); e.preventDefault();
@@ -4593,6 +4597,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
value={option.id} value={option.id}
checked={response[question.id] === option.id && !isOtherSelected} checked={response[question.id] === option.id && !isOtherSelected}
onChange={() => { onChange={() => {
markResponseDirty();
setIsOtherSelected(false); setIsOtherSelected(false);
setOtherValue(""); setOtherValue("");
setResponse({ [question.id]: option.id }); setResponse({ [question.id]: option.id });
@@ -4614,6 +4619,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
value={PLANNING_OTHER_OPTION_ID} value={PLANNING_OTHER_OPTION_ID}
checked={isOtherSelected} checked={isOtherSelected}
onChange={() => { onChange={() => {
markResponseDirty();
setIsOtherSelected(true); setIsOtherSelected(true);
setResponse({}); setResponse({});
}} }}
@@ -4630,7 +4636,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
data-testid="planning-other-input" data-testid="planning-other-input"
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")} placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue} value={otherValue}
onChange={(e) => setOtherValue(e.target.value)} onChange={(e) => {
markResponseDirty();
setOtherValue(e.target.value);
}}
/> />
</div> </div>
)} )}
@@ -4648,6 +4657,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
value={option.id} value={option.id}
checked={selected.includes(option.id)} checked={selected.includes(option.id)}
onChange={(e) => { onChange={(e) => {
markResponseDirty();
const newSelected = e.target.checked const newSelected = e.target.checked
? [...selected, option.id] ? [...selected, option.id]
: selected.filter((id) => id !== option.id); : selected.filter((id) => id !== option.id);
@@ -4670,6 +4680,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
value={PLANNING_OTHER_OPTION_ID} value={PLANNING_OTHER_OPTION_ID}
checked={isOtherSelected} checked={isOtherSelected}
onChange={(e) => { onChange={(e) => {
markResponseDirty();
setIsOtherSelected(e.target.checked); setIsOtherSelected(e.target.checked);
if (!e.target.checked) { if (!e.target.checked) {
setOtherValue(""); setOtherValue("");
@@ -4688,7 +4699,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
data-testid="planning-other-input" data-testid="planning-other-input"
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")} placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue} value={otherValue}
onChange={(e) => setOtherValue(e.target.value)} onChange={(e) => {
markResponseDirty();
setOtherValue(e.target.value);
}}
/> />
</div> </div>
)} )}
@@ -4701,6 +4715,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
<button <button
className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`} className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`}
onClick={() => { onClick={() => {
markResponseDirty();
setIsOtherSelected(false); setIsOtherSelected(false);
setOtherValue(""); setOtherValue("");
setResponse({ [question.id]: true }); setResponse({ [question.id]: true });
@@ -4712,6 +4727,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
<button <button
className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`} className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`}
onClick={() => { onClick={() => {
markResponseDirty();
setIsOtherSelected(false); setIsOtherSelected(false);
setOtherValue(""); setOtherValue("");
setResponse({ [question.id]: false }); setResponse({ [question.id]: false });
@@ -4724,6 +4740,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`} className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`}
data-testid="planning-option-other" data-testid="planning-option-other"
onClick={() => { onClick={() => {
markResponseDirty();
setIsOtherSelected(true); setIsOtherSelected(true);
setResponse({}); setResponse({});
}} }}
@@ -4740,7 +4757,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
data-testid="planning-other-input" data-testid="planning-other-input"
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")} placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue} value={otherValue}
onChange={(e) => setOtherValue(e.target.value)} onChange={(e) => {
markResponseDirty();
setOtherValue(e.target.value);
}}
/> />
</div> </div>
)} )}
@@ -4759,7 +4779,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
className="planning-textarea" className="planning-textarea"
placeholder={t("planning.additionalCommentsPlaceholder", "Add any extra context or direction...")} placeholder={t("planning.additionalCommentsPlaceholder", "Add any extra context or direction...")}
value={commentValue} value={commentValue}
onChange={(e) => setCommentValue(e.target.value)} onChange={(e) => {
markResponseDirty();
setCommentValue(e.target.value);
}}
/> />
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PlanningModeModal, resetPlanningAutoRetryAttemptsForTests } from "../PlanningModeModal"; import { PlanningModeModal, resetPlanningAutoRetryAttemptsForTests } from "../PlanningModeModal";
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers"; import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
@@ -649,6 +650,72 @@ describe("PlanningModeModal sequential flow", () => {
expect(mockConnectPlanningStream).not.toHaveBeenCalled(); expect(mockConnectPlanningStream).not.toHaveBeenCalled();
}); });
/*
FNXC:PlanningMode 2026-08-16-08:54:
A same-session background hydration may start after a user has answered but before Next is
pressed. Keep the real desktop and mobile modal question mounted through that deferred commit,
so the live action submits the locally chosen payload rather than a discarded draft.
*/
it.each(["desktop", "mobile"] as const)("submits an answer after deferred same-session hydration on %s", async (viewport) => {
mockViewportMode.mockReturnValue(viewport);
const question = viewport === "desktop"
? { id: "q-deferred-text", type: "text" as const, question: "What must stay actionable?" }
: {
id: "q-deferred-choice",
type: "single_select" as const,
question: "Which mobile answer must stay actionable?",
options: [{ id: "one", label: "First direction" }],
};
let resolveHydration!: (session: Record<string, unknown>) => void;
mockFetchAiSession.mockImplementationOnce(() => Promise.resolve({
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify(question),
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
})).mockImplementationOnce(() => new Promise((resolve) => { resolveHydration = resolve; }));
mockRespondToPlanning.mockResolvedValue({ summary: summaryWithRefinements, currentQuestion: null });
renderSession();
await screen.findByText(question.question);
const user = userEvent.setup();
if (viewport === "desktop") {
await user.type(screen.getByPlaceholderText("Type your answer here..."), "Keep this response");
} else {
await user.click(screen.getByRole("radio", { name: /other \(write your own\)/i }));
await user.type(screen.getByTestId("planning-other-input"), "Keep this mobile response");
}
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
mockPlanningSse.events?.["ai_session:updated"]?.(new MessageEvent("ai_session:updated", {
data: JSON.stringify({ ...base, type: "planning", status: "awaiting_input" }),
}));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
resolveHydration({
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify(question),
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
await Promise.resolve();
});
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-1",
viewport === "desktop"
? { "q-deferred-text": "Keep this response" }
: { _other: "Keep this mobile response" },
"project-1",
));
});
it("opens question, answer, and collapsed AI reasoning history without a Sessions toggle", async () => { it("opens question, answer, and collapsed AI reasoning history without a Sessions toggle", async () => {
mockFetchAiSession.mockResolvedValue({ mockFetchAiSession.mockResolvedValue({
...base, ...base,
@@ -1766,6 +1833,71 @@ describe("PlanningModeModal sequential flow", () => {
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
expect(screen.queryByText("Stream error")).toBeNull(); expect(screen.queryByText("Stream error")).toBeNull();
}); });
/*
FNXC:PlanningMode 2026-08-16-08:20:
Stopping a generating session restores its summary view and intentionally reuses the session id.
A recovery-poll request begun before Stop must still lose its load/turn ownership, otherwise its
stale durable question can replace the terminal plan review after the operator has stopped.
*/
it.each(["desktop", "mobile"] as const)("keeps post-Stop plan review when a pre-Stop loading poll resolves on %s", async (viewport) => {
mockViewportMode.mockReturnValue(viewport);
let resolveStalePoll!: (session: Record<string, unknown>) => void;
let fetchCount = 0;
mockFetchAiSession.mockImplementation(() => {
fetchCount += 1;
if (fetchCount === 1) {
return Promise.resolve({
...base,
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({ generationPurpose: "initial_plan" }),
});
}
return new Promise((resolve) => { resolveStalePoll = resolve; });
});
vi.useFakeTimers();
renderSession();
await act(async () => {
await Promise.resolve();
});
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(8000);
});
await act(async () => {
await Promise.resolve();
});
expect(fetchCount).toBe(2);
let resolveStop!: (value: { success: boolean }) => void;
mockStopPlanningGeneration.mockImplementationOnce(() => new Promise((resolve) => { resolveStop = resolve; }));
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await act(async () => {
await Promise.resolve();
});
expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1");
await act(async () => {
// Resolve Stop first: it restores the session ref before React cleans up the loading poll.
// The old poll then resolves in that same commit window, when only the load epoch can reject it.
resolveStop({ success: true });
await Promise.resolve();
resolveStalePoll({
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify({ id: "q-stale-poll", type: "text", question: "What did stale polling ask?" }),
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
await Promise.resolve();
});
expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.queryByText("What did stale polling ask?")).toBeNull();
});
it("can restart initial planning after stopping its first generation", async () => { it("can restart initial planning after stopping its first generation", async () => {
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />); render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } }); fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
@@ -133,6 +134,25 @@ describe("PlanningModeModal sequential layout", () => {
expect(onSubmit).toHaveBeenCalledWith({ direction: "scope" }); expect(onSubmit).toHaveBeenCalledWith({ direction: "scope" });
}); });
it("keeps a typed answer when same-question hydration replaces the committed response", async () => {
const user = userEvent.setup();
const question = { id: "q-late-hydration", type: "text" as const, question: "What should remain yours?" };
const { rerender } = render(<QuestionForm question={question} onSubmit={vi.fn()} />);
const answer = screen.getByPlaceholderText("Type your answer here...");
await user.type(answer, "Keep this draft");
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
rerender(<QuestionForm
question={question}
initialResponse={{ "q-late-hydration": "Durable answer from a late hydration" }}
onSubmit={vi.fn()}
/>);
expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Keep this draft");
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
});
/* /*
FNXC:PlanningMode 2026-08-07-03:23: FNXC:PlanningMode 2026-08-07-03:23:
Model guidance normally asks for 3–5 substantive alternatives but is not a render cap. Model guidance normally asks for 3–5 substantive alternatives but is not a render cap.