From 111c6c96cc36a02c45aa24040684b233a619dce0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 16 Aug 2026 02:26:42 -0700 Subject: [PATCH] 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) --- .changeset/fn-9117-planning-turn-ownership.md | 7 + .../suite-only-flakes-observed-register.md | 24 +++ .../app/components/PlanningModeModal.tsx | 141 ++++++++++-------- .../PlanningModeModal.planning-flow.test.tsx | 132 ++++++++++++++++ ...PlanningModeModal.ui-interactions.test.tsx | 20 +++ 5 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 .changeset/fn-9117-planning-turn-ownership.md diff --git a/.changeset/fn-9117-planning-turn-ownership.md b/.changeset/fn-9117-planning-turn-ownership.md new file mode 100644 index 0000000000..446b95ca20 --- /dev/null +++ b/.changeset/fn-9117-planning-turn-ownership.md @@ -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. diff --git a/docs/solutions/test-failures/suite-only-flakes-observed-register.md b/docs/solutions/test-failures/suite-only-flakes-observed-register.md index d5cbadf0c8..85d8937ee2 100644 --- a/docs/solutions/test-failures/suite-only-flakes-observed-register.md +++ b/docs/solutions/test-failures/suite-only-flakes-observed-register.md @@ -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. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 3df637341e..c5634b8e4a 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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(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); + }} /> )} @@ -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); + }} /> )} @@ -4701,6 +4715,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,