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:
7
.changeset/fn-9117-planning-turn-ownership.md
Normal file
7
.changeset/fn-9117-planning-turn-ownership.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -2047,6 +2047,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const loadSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
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 = null;
|
||||
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
|
||||
restores it for the session actually being loaded.
|
||||
*/
|
||||
setLinkedTaskId(null);
|
||||
setLinkedTask(null);
|
||||
setStreamingOutput("");
|
||||
setResponseHistory([]);
|
||||
setConversationHistory([]);
|
||||
setEditedSummary(null);
|
||||
setRunningSummary(null);
|
||||
setWorkspaceQuestion(null);
|
||||
setLoadedSessionTitle(null);
|
||||
setIsRetrying(false);
|
||||
setIsRefiningSummary(false);
|
||||
refineSummaryInFlightRef.current = false;
|
||||
setGenerationStartTime(null);
|
||||
// FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader,
|
||||
// not the generation pane — only a fetched status of "generating" enters `loading` below.
|
||||
viewRef.current = { type: "session_loading" };
|
||||
setView({ type: "session_loading" });
|
||||
if (!preservesActiveWorkspace) {
|
||||
setLinkedTaskId(null);
|
||||
setLinkedTask(null);
|
||||
setStreamingOutput("");
|
||||
setResponseHistory([]);
|
||||
setConversationHistory([]);
|
||||
setEditedSummary(null);
|
||||
setRunningSummary(null);
|
||||
setWorkspaceQuestion(null);
|
||||
setLoadedSessionTitle(null);
|
||||
setIsRetrying(false);
|
||||
setIsRefiningSummary(false);
|
||||
refineSummaryInFlightRef.current = false;
|
||||
setGenerationStartTime(null);
|
||||
// FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader,
|
||||
// not the generation pane — only a fetched status of "generating" enters `loading` below.
|
||||
viewRef.current = { type: "session_loading" };
|
||||
setView({ type: "session_loading" });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await fetchAiSession(sessionId);
|
||||
@@ -2827,44 +2838,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
const handleSubmitResponse = useCallback(
|
||||
async (responses: QuestionResponse) => {
|
||||
if (view.type !== "question") return;
|
||||
|
||||
const { session } = view;
|
||||
const sessionId = session.sessionId;
|
||||
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 } });
|
||||
}
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
const activeQuestion = workspaceQuestion;
|
||||
if (!sessionId || !activeQuestion) {
|
||||
setError(t("planning.noActiveQuestion", "No active question is available. Wait for the interview to resume."));
|
||||
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);
|
||||
const responseTurnEpoch = ++planningTurnEpochRef.current;
|
||||
@@ -3000,7 +2987,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
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 () => {
|
||||
@@ -4433,6 +4420,11 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
const [otherValue, setOtherValue] = useState("");
|
||||
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({
|
||||
value: textValue,
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (restoredQuestionIdRef.current === question.id && dirtyResponseRef.current) return;
|
||||
const prior = initialResponse ?? {};
|
||||
const other = typeof prior[PLANNING_OTHER_RESPONSE_KEY] === "string" ? prior[PLANNING_OTHER_RESPONSE_KEY] : "";
|
||||
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);
|
||||
setTextValue(typeof text === "string" ? text : "");
|
||||
setCommentValue(typeof prior._comment === "string" ? prior._comment : "");
|
||||
@@ -4571,7 +4572,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
className="planning-textarea"
|
||||
placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")}
|
||||
value={textValue}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
setTextValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
|
||||
e.preventDefault();
|
||||
@@ -4593,6 +4597,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
value={option.id}
|
||||
checked={response[question.id] === option.id && !isOtherSelected}
|
||||
onChange={() => {
|
||||
markResponseDirty();
|
||||
setIsOtherSelected(false);
|
||||
setOtherValue("");
|
||||
setResponse({ [question.id]: option.id });
|
||||
@@ -4614,6 +4619,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
value={PLANNING_OTHER_OPTION_ID}
|
||||
checked={isOtherSelected}
|
||||
onChange={() => {
|
||||
markResponseDirty();
|
||||
setIsOtherSelected(true);
|
||||
setResponse({});
|
||||
}}
|
||||
@@ -4630,7 +4636,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
data-testid="planning-other-input"
|
||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||
value={otherValue}
|
||||
onChange={(e) => setOtherValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
setOtherValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -4648,6 +4657,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
value={option.id}
|
||||
checked={selected.includes(option.id)}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
const newSelected = e.target.checked
|
||||
? [...selected, option.id]
|
||||
: selected.filter((id) => id !== option.id);
|
||||
@@ -4670,6 +4680,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
value={PLANNING_OTHER_OPTION_ID}
|
||||
checked={isOtherSelected}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
setIsOtherSelected(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setOtherValue("");
|
||||
@@ -4688,7 +4699,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
data-testid="planning-other-input"
|
||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||
value={otherValue}
|
||||
onChange={(e) => setOtherValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
setOtherValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -4701,6 +4715,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`}
|
||||
onClick={() => {
|
||||
markResponseDirty();
|
||||
setIsOtherSelected(false);
|
||||
setOtherValue("");
|
||||
setResponse({ [question.id]: true });
|
||||
@@ -4712,6 +4727,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`}
|
||||
onClick={() => {
|
||||
markResponseDirty();
|
||||
setIsOtherSelected(false);
|
||||
setOtherValue("");
|
||||
setResponse({ [question.id]: false });
|
||||
@@ -4724,6 +4740,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`}
|
||||
data-testid="planning-option-other"
|
||||
onClick={() => {
|
||||
markResponseDirty();
|
||||
setIsOtherSelected(true);
|
||||
setResponse({});
|
||||
}}
|
||||
@@ -4740,7 +4757,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
data-testid="planning-other-input"
|
||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||
value={otherValue}
|
||||
onChange={(e) => setOtherValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
setOtherValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -4759,7 +4779,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
||||
className="planning-textarea"
|
||||
placeholder={t("planning.additionalCommentsPlaceholder", "Add any extra context or direction...")}
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
markResponseDirty();
|
||||
setCommentValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PlanningModeModal, resetPlanningAutoRetryAttemptsForTests } from "../PlanningModeModal";
|
||||
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();
|
||||
});
|
||||
|
||||
/*
|
||||
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 () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
@@ -1766,6 +1833,71 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
|
||||
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 () => {
|
||||
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" } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
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 { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
@@ -133,6 +134,25 @@ describe("PlanningModeModal sequential layout", () => {
|
||||
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:
|
||||
Model guidance normally asks for 3–5 substantive alternatives but is not a render cap.
|
||||
|
||||
Reference in New Issue
Block a user