FN-8420: keep Planning Mode interviews open until validation

Keep the Planning Mode workspace active until the operator explicitly validates its running plan.

- Preserve the answered-question, current-question, and running-plan panes through stream updates and recoverable states.
- Require durable validation before exposing terminal summaries and task creation actions.
- Make every answer request one new high-impact question and update Planning Mode guidance, coverage, and release notes.

Files changed:
 .../fn-8420-planning-mode-user-validation.md       |   7 +
 docs/dashboard-guide.md                            |   6 +-
 docs/settings-reference.md                         |   2 +-
 .../dashboard/app/components/PlanningModeModal.tsx | 163 +++++++++++++--------
 .../__tests__/PlanningModeModal.initial.test.tsx   |  20 ++-
 .../PlanningModeModal.planning-flow.test.tsx       |  99 +++++++++----
 .../PlanningModeModal.ui-interactions.test.tsx     |  14 ++
 .../app/planning-browser-e2e-fixture.html          |   5 +
 .../dashboard/app/planning-browser-e2e-fixture.tsx | 131 +++++++++++++++++
 .../src/__tests__/planning-browser-e2e.test.ts     | 115 +++++++++++++++
 .../__tests__/planning-infinite-interview.test.ts  |   2 +
 .../planning-interview-formatters.test.ts          |   8 +
 packages/dashboard/src/planning.ts                 |  16 +-
 13 files changed, 485 insertions(+), 103 deletions(-)

Fusion-Task-Id: FN-8420

Fusion-Task-Lineage: af88729b-98c1-4141-bad3-a17ddb497181

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 02:01:58 -07:00
parent 4f0d89e106
commit 8166c80bad
13 changed files with 485 additions and 103 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Planning Mode interviews open until you explicitly validate the running plan.
category: fix
dev: Preserves the active three-pane workspace across summary, loading, and recoverable-error events.

View File

@@ -529,7 +529,7 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
Use **New session** to restart planning with a different idea.
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
Planning Mode keeps the running plan visible beside answered-question history and the current question; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview; the AI never ends it on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
Planning Mode keeps the running plan visible beside answered-question history and the current question; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown; use **Sessions** to return to the saved-session list. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions.
@@ -2124,9 +2124,9 @@ If the endpoint is unavailable on the running dashboard build, the response will
### Planner clarification notifications
Planning Mode always asks and waits for at least one clarifying question before producing a plan. The advanced **follow-up clarification questions** setting is a per-session override initialized from the global preference: when disabled, Planning Mode still asks one mandatory question, then requests a final summary after the answer; when enabled, it may ask further proactive questions. The final summary deepening checkpoint is unchanged.
<!-- FNXC:PlanningMode 2026-07-20-01:00: Planning interviews are always infinite and user-validated. The former follow-up toggle cannot suppress questions or produce a final summary; the dashboard starts each interview in the full questioning mode. -->
When enabled, a proactive question holds the planner at `awaiting_input`, sends the configured `planning-awaiting-input` ntfy event, and delivers a dashboard mailbox message that links the operator back to planner chat. Mailbox delivery does not depend on ntfy configuration and is deduplicated by session/question across restarts.
Planning Mode asks another focused question after every answer until you select **Validate plan**. Each `awaiting_input` question can send the configured `planning-awaiting-input` ntfy event and delivers a dashboard mailbox message that links the operator back to the Planning view. Mailbox delivery does not depend on ntfy configuration and is deduplicated by session/question across restarts.
### Mobile footer quick actions

View File

@@ -71,7 +71,7 @@ Fallback thinking-level values are applied at runtime when Fusion swaps from the
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | `undefined` | Default reasoning effort for AI sessions. `xhigh` requests maximum reasoning effort; Claude CLI adapters map it to `high` for non-Opus models and `max` for Opus models. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. |
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
| `agentClarificationEnabled` | `boolean` | `false` | Allow follow-up Planning Mode clarification questions. Planning Mode always asks one mandatory question first; when disabled it requests a final summary after that answer, while enabled follow-ups notify configured ntfy recipients and the dashboard mailbox. |
| `agentClarificationEnabled` | `boolean` | `false` | Legacy default for programmatic Planning Mode session notification eligibility. Dashboard Planning Mode always starts its infinite, user-validated interview with follow-up questions enabled; this setting no longer suppresses questions or creates a final summary. |
| `failureNotificationMode` | `"sticky-only" \| "terminal-only" \| "all"` | `"sticky-only"` | Failure notification behavior. `sticky-only` defers failed-task notifications by `failureNotificationDelayMs` and suppresses transient self-recoveries. `terminal-only` suppresses while auto-retry is still active and only dispatches when `paused === true` or `column === "in-review"` with `status === "failed"`. `all` restores legacy immediate failure notifications. |
| `failureNotificationDelayMs` | `number` | `30000` | Delay window (ms) before evaluating/sending a `failed` notification in `sticky-only` and `terminal-only` modes. Set `0` for immediate dispatch in legacy `all` mode. |
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |

View File

@@ -35,7 +35,6 @@ import {
summarizePlanningDraftTitle,
updatePlanningSessionTitle,
updateGlobalSettings,
fetchGlobalSettings,
type PlanningSession,
type SubtaskItem,
type PlanningSubtaskDraft,
@@ -107,6 +106,27 @@ type ViewState =
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" };
/**
* FNXC:PlanningMode 2026-07-20-00:00:
* A persisted planning `result` is an evolving running plan, not proof that the interview ended.
* Only the explicit Validate action writes this durable marker, so reload and poll paths must use
* it before exposing terminal summary/create-task UI.
*/
function isValidatedPlanningSession(session: { inputPayload?: string | null }): boolean {
try {
const payload: unknown = JSON.parse(session.inputPayload ?? "");
/*
FNXC:PlanningMode 2026-07-20-01:15:
Terminal Planning UI is an explicit user-validation privilege, not a legacy-session inference.
Missing or malformed persistence may contain a running plan, so only a durable `validated: true`
marker can reveal SummaryView and task-creation actions after reload, polling, or SSE updates.
*/
return typeof payload === "object" && payload !== null && (payload as { validated?: unknown }).validated === true;
} catch {
return false;
}
}
function getExamplePlans(t: TFunction<"app">): string[] {
return [
t("planning.examplePlan1", "Build a user authentication system with login and signup"),
@@ -446,8 +466,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [planningThinkingLevel, setPlanningThinkingLevel] = useState<ThinkingLevel | "">("");
const [clarificationEnabled, setClarificationEnabled] = useState(true);
const [clarificationSettingsLoading, setClarificationSettingsLoading] = useState(true);
/*
FNXC:PlanningMode 2026-07-20-00:45:
An active interview keeps answered history in the left pane during question, generation, and recoverable-error states.
Session navigation is an explicit header action so a transient turn cannot replace the three-pane workspace.
*/
const [showSessionList, setShowSessionList] = useState(false);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -662,14 +686,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
});
setStreamingOutput("");
} else if (session.status === "complete" && session.result) {
resetPlanningAutoRetryBudget();
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setRunningSummary(summary);
if (isValidatedPlanningSession(session)) {
resetPlanningAutoRetryBudget();
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
} else {
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary },
errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."),
});
}
setStreamingOutput("");
} else if (session.status === "error") {
const errorMessage = session.error || t("planning.sessionFailed2", "Session failed");
@@ -883,13 +916,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
});
}
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary: normalizedSummary },
summary: normalizedSummary,
});
setEditedSummary(normalizedSummary);
/*
FNXC:PlanningMode 2026-07-20-00:00:
The server broadcasts `summary` on every interview turn before or after its next
question. It refreshes the right running-plan pane only; Validate is the sole action
allowed to enter terminal SummaryView, preventing a first-answer SSE race from ending
the interview.
*/
setRunningSummary(normalizedSummary);
setView((previous) => previous.type === "question"
? {
...previous,
session: { ...previous.session, summary: normalizedSummary },
}
: previous);
setStreamingOutput("");
},
onError: (message) => {
@@ -1024,15 +1064,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!session.result) {
throw new Error("Planning session is complete but has no result.");
}
resetPlanningAutoRetryBudget();
const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId: session.id, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setRunningSummary(summary);
if (isValidatedPlanningSession(session)) {
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId: session.id, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
} else {
setView({
type: "error",
session: { sessionId: session.id, currentQuestion: null, summary },
errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."),
});
}
} else if (session.status === "error") {
setView({
type: "error",
@@ -1098,7 +1147,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
startPlanningAutoRetryRef.current = startPlanningAutoRetry;
const handleStartPlanning = useCallback(async (planOverride?: string) => {
if (clarificationSettingsLoading) return;
const plan = planOverride ?? initialPlan;
const startedPlan = plan.trim();
if (!startedPlan) return;
@@ -1126,13 +1174,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
startedPlan,
projectId,
modelOverride,
{ clarificationEnabled },
{ clarificationEnabled: true },
draftSessionId ?? undefined,
);
draftSessionIdRef.current = null;
currentSessionIdRef.current = sessionId;
liveGenerationSessionIdRef.current = sessionId;
setSelectedSessionId(sessionId);
setShowSessionList(false);
connectToPlanningStream(sessionId);
setResponseHistory([]);
@@ -1143,8 +1192,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
currentSessionIdRef.current = null;
}
}, [
clarificationEnabled,
clarificationSettingsLoading,
connectToPlanningStream,
initialPlan,
planningModelId,
@@ -1154,18 +1201,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget,
]);
useEffect(() => {
if (!isOpen) return;
let active = true;
setClarificationSettingsLoading(true);
void fetchGlobalSettings()
.then((settings) => { if (active) setClarificationEnabled(settings.agentClarificationEnabled !== false); })
// A missing settings response keeps the default full interview; disabled only limits follow-ups.
.catch(() => { if (active) setClarificationEnabled(true); })
.finally(() => { if (active) setClarificationSettingsLoading(false); });
return () => { active = false; };
}, [isOpen]);
/*
FNXC:PlanningFocus 2026-06-23-00:00:
Viewing Planning Mode must not auto-focus the initial composer because mobile browsers open the keyboard before the user chooses to type. Keep the textarea ref for autosize and explicit user focus only; populated initialPlan handoffs still auto-start through the separate effect below.
@@ -1180,7 +1215,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// Auto-start planning when initialPlan prop is provided
useEffect(() => {
if (isOpen && initialPlanProp && !clarificationSettingsLoading && !hasAutoStartedRef.current && view.type === "initial") {
if (isOpen && initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") {
setInitialPlan(initialPlanProp);
// Use a small timeout to allow state update to propagate before starting
const timer = setTimeout(() => {
@@ -1206,7 +1241,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setInitialPlan(persisted);
}
}
}, [isOpen, initialPlanProp, clarificationSettingsLoading, view.type, handleStartPlanning, projectId]);
}, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]);
// Load a specific persisted session into the right pane.
const loadSession = useCallback(
@@ -1360,11 +1395,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
connectToPlanningStream(sessionId);
} else if (session.status === "complete" && session.result) {
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
const summary = persistedRunningSummary ?? normalizePlanningSummary(JSON.parse(session.result));
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
setRunningSummary(summary);
if (isValidatedPlanningSession(session)) {
resetPlanningAutoRetryBudget();
clearPlanningDescription(projectId);
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
} else {
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary },
errorMessage: t("planning.awaitingValidationState", "This plan is still being prepared. Retry to continue the interview."),
});
}
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
@@ -1504,10 +1548,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleSelectSession = useCallback(
(sessionId: string) => {
if (selectedSessionId === sessionId) {
setShowSessionList(false);
setMobileShowDetail(true);
return;
}
setSelectedSessionId(sessionId);
setShowSessionList(false);
setMobileShowDetail(true);
void loadSession(sessionId);
},
@@ -1528,11 +1574,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const preserveActiveDraft = selectedSessionId === null && viewRef.current.type === "initial";
resetDetailState({ preserveInitialPlan: preserveActiveDraft });
setSelectedSessionId(null);
setShowSessionList(false);
setMobileShowDetail(true);
setNewSessionFocusSignal((signal) => signal + 1);
}, [resetDetailState, resumeSessionId, selectedSessionId]);
const handleBackToList = useCallback(() => {
setShowSessionList(true);
setMobileShowDetail(false);
}, []);
@@ -2172,7 +2220,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
Header icon mirrors MissionManager's <Target size={20} className="mission-manager__header-icon" />: same size (20) and same var(--todo) tint + flex-shrink:0, applied via the scoped .planning-modal--embedded .modal-header--embedded .detail-title-row > svg rule (it overrides the shared icon-triage brown so the two headers read as siblings).
*/}
<Lightbulb size={20} className="icon-triage" />
{view.type === "question" && activeSessionTitle && isRenamingSession ? (
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && isRenamingSession ? (
<input
className="input planning-session-title-input"
aria-label={t("planning.renameSession", "Rename session")}
@@ -2183,10 +2231,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
autoFocus
/>
) : (
<><h3>{view.type === "question" && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}</h3>
{view.type === "question" && activeSessionTitle && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</>
<><h3>{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}</h3>
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</>
)}
</div>
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && !isMobile && (
<button type="button" className="btn" onClick={() => setShowSessionList((current) => !current)}>
{t("planning.sessions", "Sessions")}
</button>
)}
{!isEmbedded && (
<div className="modal-header-actions">
<button className="modal-close" onClick={handleClose} aria-label={t("common.close", "Close")}>
@@ -2201,10 +2254,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list"
}`}
>
{view.type === "question" ? (
{selectedSessionId && !showSessionList && (view.type === "question" || view.type === "loading" || view.type === "error") ? (
<AnsweredQuestionHistory
entries={conversationHistory}
selectedQuestionId={editingQuestionId ?? view.session.currentQuestion?.id}
selectedQuestionId={editingQuestionId ?? (view.type === "question" ? view.session.currentQuestion?.id : undefined)}
isPending={isHistoryEditPending}
onSelect={(entry) => void handleSelectAnsweredQuestion(entry)}
/>
@@ -2416,12 +2469,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
</div>
<div className="planning-advanced-section">
<label className="checkbox-label" htmlFor="planning-clarification-enabled">
<input id="planning-clarification-enabled" type="checkbox" checked={clarificationEnabled} disabled={clarificationSettingsLoading} onChange={(event) => setClarificationEnabled(event.target.checked)} />
{t("planning.agentClarification", " Allow follow-up clarification questions")}
</label>
</div>
</div>
</OnboardingDisclosure>
</div>
@@ -2430,7 +2477,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<button
className="btn btn-primary planning-start-btn"
onClick={() => handleStartPlanning()}
disabled={!initialPlan.trim() || clarificationSettingsLoading}
disabled={!initialPlan.trim()}
>
<Lightbulb size={16} className="icon-mr-8" />
{t("planning.startPlanning", "Start Planning")}

View File

@@ -310,7 +310,7 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from handoff", undefined, undefined, {
clarificationEnabled: false,
clarificationEnabled: true,
}, undefined);
});
@@ -400,6 +400,7 @@ describe("PlanningModeModal", () => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-existing");
});
fireEvent.click(screen.getByRole("button", { name: "Sessions" }));
fireEvent.click(screen.getByRole("button", { name: "New session" }));
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
@@ -560,7 +561,6 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.change(textarea, { target: { value: "Test plan" } });
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false);
@@ -655,8 +655,9 @@ describe("PlanningModeModal", () => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, {
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
thinkingLevel: undefined,
}, {
clarificationEnabled: false,
clarificationEnabled: true,
}, undefined);
});
});
@@ -691,7 +692,7 @@ describe("PlanningModeModal", () => {
expect(disclosureScope.getByText("openai/gpt-4o")).toBeDefined();
});
expect(disclosureScope.getByText(/Selects which model runs the planning interview/)).toBeDefined();
expect(disclosureScope.getByLabelText("Allow follow-up clarification questions")).toBeDefined();
expect(disclosureScope.queryByLabelText("Allow follow-up clarification questions")).toBeNull();
});
it("calls startPlanningStreaming without model override when none selected", async () => {
@@ -705,15 +706,13 @@ describe("PlanningModeModal", () => {
/>
);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
clarificationEnabled: false,
clarificationEnabled: true,
}, undefined);
});
});
@@ -768,7 +767,7 @@ describe("PlanningModeModal", () => {
undefined,
undefined,
{
clarificationEnabled: false,
clarificationEnabled: true,
},
"draft-123",
);
@@ -853,7 +852,7 @@ describe("PlanningModeModal", () => {
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
clarificationEnabled: false,
clarificationEnabled: true,
}, undefined);
}, { timeout: 2000 });
@@ -878,7 +877,7 @@ describe("PlanningModeModal", () => {
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
clarificationEnabled: false,
clarificationEnabled: true,
}, undefined);
}, { timeout: 2000 });
});
@@ -906,7 +905,6 @@ describe("PlanningModeModal", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Draft a migration plan" },
});

View File

@@ -273,7 +273,7 @@ describe("PlanningModeModal", () => {
});
describe("Planning flow", () => {
it.each(["desktop", "mobile"] as const)("FN-6977 renders malformed live summary without generic error on %s", async (viewportMode) => {
it.each(["desktop", "mobile"] as const)("FN-6977 keeps malformed live running plans non-terminal on %s", async (viewportMode) => {
mockViewport(viewportMode);
mockStartPlanningStreaming.mockResolvedValueOnce({ sessionId: `session-fn-6977-live-${viewportMode}` });
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -307,13 +307,53 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
expect(screen.getByRole("complementary", { name: "Running plan" })).toBeDefined();
});
expect(screen.getByText("Live Planning Mode summary omitted deliverable arrays")).toBeDefined();
expect(screen.queryByText(/Something went wrong/i)).toBeNull();
expect(screen.getByRole("button", { name: "Create Single Task" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeEnabled();
expect(screen.queryByText("Planning Complete!")).toBeNull();
expect(screen.queryByRole("button", { name: "Create Single Task" })).toBeNull();
expect(screen.getByRole("button", { name: "Validate plan" })).toBeEnabled();
});
it("keeps the interview open when running-plan summaries arrive before and after questions", async () => {
let streamHandlers: Record<string, ((value: any) => void) | undefined> = {};
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
queuePlanningStreamEvent(() => {
handlers.onSummary?.({ ...mockSummary, title: "Before question running plan" });
handlers.onQuestion?.(mockQuestion);
});
return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Keep the interview open" },
});
fireEvent.click(screen.getByText("Start Planning"));
expect(await screen.findByText(mockQuestion.question)).toBeDefined();
expect(screen.getByText("Before question running plan")).toBeDefined();
expect(screen.queryByText("Planning Complete!")).toBeNull();
await act(async () => {
streamHandlers.onSummary?.({ ...mockSummary, title: "After question running plan" });
});
expect(screen.getByText(mockQuestion.question)).toBeDefined();
expect(screen.getByText("After question running plan")).toBeDefined();
expect(screen.queryByText("Planning Complete!")).toBeNull();
expect(screen.queryByRole("button", { name: "Create Single Task" })).toBeNull();
});
it("starts planning and shows question view", async () => {
@@ -1328,7 +1368,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-spinner-single-task",
inputPayload: JSON.stringify({ initialPlan: "Recover and create" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -1394,7 +1434,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-spinner-isolation-single-task",
inputPayload: JSON.stringify({ initialPlan: "Recover and create a single task" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create a single task" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -1463,7 +1503,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-spinner-breakdown-start",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and break down" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -1529,7 +1569,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-spinner-isolation-breakdown",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down into tasks" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and break down into tasks" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -1598,7 +1638,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-spinner-breakdown-create",
inputPayload: JSON.stringify({ initialPlan: "Recover and create tasks" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create tasks" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -1669,7 +1709,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Malformed summary without arrays",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed summary" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover malformed summary" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(malformedSummary),
@@ -1713,7 +1753,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Malformed summary create task",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed summary and create" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover malformed summary and create" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(malformedSummary),
@@ -1779,7 +1819,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Malformed summary breakdown",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed summary and break down" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover malformed summary and break down" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(malformedSummary),
@@ -1931,7 +1971,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-ready planning output",
inputPayload: JSON.stringify({ initialPlan: "Build resilient planning resume" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Build resilient planning resume" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -2100,7 +2140,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: completedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Recover completed session" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover completed session" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(completedSummary),
@@ -2392,7 +2432,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Malformed result session",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed result" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover malformed result" }),
conversationHistory: "[]",
currentQuestion: null,
result: "{",
@@ -2453,7 +2493,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Reopen recover session",
inputPayload: JSON.stringify({ initialPlan: "Reopen recover session" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Reopen recover session" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(reopenedSummary),
@@ -2468,7 +2508,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Reopen recover session",
inputPayload: JSON.stringify({ initialPlan: "Reopen recover session" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Reopen recover session" }),
conversationHistory: "[]",
currentQuestion: null,
result: "{",
@@ -2611,7 +2651,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-to-task",
inputPayload: JSON.stringify({ initialPlan: "Recover and create" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -2685,7 +2725,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-to-task-priority",
inputPayload: JSON.stringify({ initialPlan: "Recover and create with priority" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create with priority" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -2742,7 +2782,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-branch-controls",
inputPayload: JSON.stringify({ initialPlan: "Recover and create with branch controls" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create with branch controls" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -2833,7 +2873,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-branch-breakdown",
inputPayload: JSON.stringify({ initialPlan: "Recover and create breakdown with branch controls" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and create breakdown with branch controls" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -2920,7 +2960,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-to-breakdown-priority",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down with priority" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and break down with priority" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -2994,7 +3034,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-to-breakdown-compact",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down compactly" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and break down compactly" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -3084,7 +3124,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-to-breakdown-add-subtask",
inputPayload: JSON.stringify({ initialPlan: "Recover and add a subtask" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and add a subtask" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -3172,7 +3212,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: "Resume-to-breakdown-remove-subtask",
inputPayload: JSON.stringify({ initialPlan: "Recover and remove a subtask" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Recover and remove a subtask" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -3268,7 +3308,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -3330,7 +3370,7 @@ describe("PlanningModeModal", () => {
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
inputPayload: JSON.stringify({ validated: true, initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: null,
result: JSON.stringify(resumedSummary),
@@ -3580,6 +3620,7 @@ describe("PlanningModeModal", () => {
await screen.findByText("What is the scope?");
expect(screen.getAllByRole("button", { name: "Next question" })).toHaveLength(1);
expect(screen.getByRole("radio", { name: "Other (write your own)" })).toBeDefined();
expect(screen.queryByLabelText(/follow-up clarification questions/i)).toBeNull();
expect(screen.queryByRole("button", { name: "Back" })).toBeNull();
expect(screen.queryByRole("button", { name: /Copy prompt/i })).toBeNull();
expect(screen.queryByText(/Question .* of ~3/i)).toBeNull();
@@ -3613,6 +3654,8 @@ describe("PlanningModeModal", () => {
render(<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} resumeSessionId={`session-${status}`} />);
expect(await screen.findByRole("complementary", { name: "Running plan" })).toHaveTextContent(mockSummary.title);
expect(screen.getByRole("complementary", { name: "Answered questions" })).toBeDefined();
expect(screen.queryByRole("complementary", { name: "Planning sessions" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Validate plan" }));
await waitFor(() => expect(mockValidatePlanningSession).toHaveBeenCalledWith(`session-${status}`, undefined));
});

View File

@@ -683,6 +683,11 @@ describe("PlanningModeModal", () => {
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Validate plan" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Validate plan" }));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
@@ -1049,6 +1054,15 @@ describe("PlanningModeModal", () => {
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Validate plan" })).toBeDefined();
});
mockValidatePlanningSession.mockResolvedValueOnce({
summary: { ...mockSummary, description },
validated: true,
});
fireEvent.click(screen.getByRole("button", { name: "Validate plan" }));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});

View File

@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Planning Mode browser E2E</title></head>
<body><div id="root"></div><script type="module" src="/app/planning-browser-e2e-fixture.tsx"></script></body>
</html>

View File

@@ -0,0 +1,131 @@
import React from "react";
import { createRoot } from "react-dom/client";
import i18n from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { PlanningModeModal } from "./components/PlanningModeModal";
import { ToastProvider } from "./hooks/useToast";
import { NavigationHistoryProvider } from "./hooks/useNavigationHistory";
const summary = {
title: "Adaptive planning workflow",
description: "An evolving plan assembled from the interview answers.",
suggestedSize: "M",
priority: "normal",
suggestedDependencies: [],
keyDeliverables: ["Adaptive questions", "Validated task"],
};
const questions = [
{
id: "q-goal",
type: "single_select",
question: "Which user outcome matters most?",
options: [
{ id: "speed", label: "Speed", pros: "Fast delivery", cons: "Less breadth" },
{ id: "depth", label: "Depth", pros: "More complete", cons: "Takes longer" },
],
},
{
id: "q-audience",
type: "single_select",
question: "Who should receive this first?",
options: [
{ id: "operators", label: "Operators", pros: "Immediate feedback", cons: "Narrow audience" },
{ id: "everyone", label: "Everyone", pros: "Broad value", cons: "More coordination" },
],
},
{
id: "q-rollout",
type: "single_select",
question: "How should the rollout be measured?",
options: [
{ id: "pilot", label: "Pilot", pros: "Lower risk", cons: "Slower reach" },
{ id: "release", label: "Release", pros: "Faster reach", cons: "Higher risk" },
],
},
];
let answerCount = 0;
const streams = new Set<MockEventSource>();
class MockEventSource {
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readyState = 1;
constructor(_url: string) {
streams.add(this);
queueMicrotask(() => this.onopen?.(new Event("open")));
}
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: MessageEvent) => void): void {
this.listeners.get(type)?.delete(listener);
}
close(): void { this.readyState = 2; streams.delete(this); }
emit(type: string, data: unknown): void {
const event = { data: JSON.stringify(data) } as MessageEvent;
this.listeners.get(type)?.forEach((listener) => listener(event));
}
}
window.EventSource = MockEventSource as unknown as typeof EventSource;
function emitTurn(questionIndex: number): void {
setTimeout(() => {
streams.forEach((stream) => stream.emit("summary", summary));
streams.forEach((stream) => stream.emit("question", questions[questionIndex]));
}, 20);
}
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const url = String(input);
const method = init.method ?? "GET";
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
if (url.includes("/planning/start-streaming") && method === "POST") {
answerCount = 0;
emitTurn(0);
return json({ sessionId: "planning-browser-e2e" });
}
if (url.includes("/planning/respond") && method === "POST") {
answerCount += 1;
emitTurn(answerCount);
return json({ sessionId: "planning-browser-e2e", currentQuestion: questions[answerCount], summary });
}
if (url.includes("/planning/planning-browser-e2e/back") && method === "POST") {
answerCount = 0;
emitTurn(0);
return json({ currentQuestion: questions[0], summary, history: [] });
}
if (url.includes("/planning/planning-browser-e2e/validate") && method === "POST") return json({ summary, validated: true });
if (url.includes("/planning/create-task") && method === "POST") return json({ id: "FN-BROWSER", description: summary.description, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
if (url.includes("/ai-sessions")) return json({ sessions: [] });
if (url.includes("/models")) return json({ models: [], favoriteProviders: [], favoriteModels: [] });
if (url.includes("/settings")) return json({});
if (url.includes("/planning/create-draft")) return json({ sessionId: "planning-browser-e2e", title: "Adaptive planning workflow" });
if (url.includes("/planning/")) return json({ success: true });
return originalFetch(input, init);
};
void i18n.use(initReactI18next).init({ lng: "en", fallbackLng: "en", resources: { en: { app: {} } }, interpolation: { escapeValue: false } });
createRoot(document.getElementById("root")!).render(
<I18nextProvider i18n={i18n}>
<NavigationHistoryProvider value={{ pushNav: () => undefined, replaceCurrent: () => undefined, removeNav: () => undefined }}>
<ToastProvider>
<PlanningModeModal
isOpen
onClose={() => undefined}
onTaskCreated={(task) => { document.body.dataset.createdTask = task.id; }}
onTasksCreated={() => undefined}
tasks={[]}
presentation="embedded"
/>
</ToastProvider>
</NavigationHistoryProvider>
</I18nextProvider>,
);

View File

@@ -0,0 +1,115 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createServer, type ViteDevServer } from "vite";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
// playwright-core is deliberately owned by @fusion/engine because its review-video lane
// also drives Chromium. Resolve that declared workspace dependency without making the
// dashboard package depend on a second copy of the browser protocol client.
const requireFromEngine = createRequire(new URL("../../../engine/package.json", import.meta.url));
const { chromium } = requireFromEngine("playwright-core") as {
chromium: { launch(options: { executablePath: string; headless: boolean }): Promise<Browser> };
};
type Browser = {
newPage(options: { viewport: { width: number; height: number } }): Promise<Page>;
close(): Promise<void>;
};
type Page = {
goto(url: string): Promise<unknown>;
getByLabel(name: string): Locator;
getByRole(role: string, options: { name: string | RegExp }): Locator;
getByText(text: string): Locator;
locator(selector: string): Locator;
close(): Promise<void>;
waitForTimeout(timeout: number): Promise<void>;
evaluate<T>(pageFunction: () => T): Promise<T>;
on(event: "console" | "pageerror", handler: (event: { text?(): string; message?: string }) => void): void;
};
type Locator = {
getByRole(role: string, options: { name: string | RegExp }): Locator;
fill(value: string): Promise<void>;
click(): Promise<void>;
check(): Promise<void>;
isVisible(): Promise<boolean>;
waitFor(options: { state: "visible"; timeout?: number }): Promise<void>;
getAttribute(name: string): Promise<string | null>;
};
const browserCandidates = process.platform === "darwin"
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
const executablePath = [process.env.FUSION_BROWSER_SMOKE_BROWSER, process.env.CHROME_BIN, ...browserCandidates]
.find((candidate): candidate is string => Boolean(candidate) && existsSync(candidate));
async function expectVisible(locator: Locator): Promise<void> {
await locator.waitFor({ state: "visible", timeout: 5_000 });
expect(await locator.isVisible()).toBe(true);
}
/*
FNXC:PlanningModeBrowserE2E 2026-07-20-01:45:
FN-8420 requires a real Chromium flow through the production PlanningModeModal, not a jsdom-only
stream assertion. The Vite fixture uses deterministic planning API and SSE stubs so this browser
lane proves the user-visible raw idea, adaptive turns, history branch, validation, and task creation
contract without a live model or polling delay.
*/
describe.runIf(executablePath)("Planning Mode browser E2E", () => {
let server: ViteDevServer;
let browser: Browser;
let baseUrl: string;
beforeAll(async () => {
server = await createServer({ root: process.cwd(), server: { host: "127.0.0.1", port: 0 }, logLevel: "error" });
await server.listen();
baseUrl = server.resolvedUrls?.local[0] ?? "";
browser = await chromium.launch({ executablePath, headless: true });
}, 30_000);
afterAll(async () => {
await browser?.close();
// Vite's close() also awaits its module graph workers, which are not part of this
// browser assertion and can remain alive after the fixture's mocked SSE channel.
// Close the actual listening socket and HMR channel directly instead.
server?.ws.close();
server?.httpServer?.closeAllConnections?.();
await new Promise<void>((resolve, reject) => server.httpServer?.close((error) => error ? reject(error) : resolve()));
await server.watcher.close();
await server.pluginContainer.close();
}, 10_000);
it("keeps an adaptive interview open until Validate and then creates a task", async () => {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on("console", (event) => console.log(`[planning-browser-e2e] ${event.text?.() ?? ""}`));
page.on("pageerror", (event) => console.error(`[planning-browser-e2e] ${event.message ?? ""}`));
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html`);
await page.getByLabel("What do you want to build?").fill("Make Planning Mode adaptive");
await page.getByRole("button", { name: "Start Planning" }).click();
await expectVisible(page.getByText("Which user outcome matters most?"));
await expectVisible(page.getByLabel("Running plan").getByRole("heading", { name: "Adaptive planning workflow" }));
await expectVisible(page.getByRole("button", { name: "Validate plan" }));
await page.getByLabel("Speed").check();
await page.getByRole("button", { name: "Next question" }).click();
await expectVisible(page.getByText("Who should receive this first?"));
await expectVisible(page.getByText("Which user outcome matters most?"));
expect(await page.getByRole("button", { name: "Create Single Task" }).isVisible()).toBe(false);
await page.getByRole("button", { name: /Edit answer for Which user outcome matters most/ }).click();
await expectVisible(page.getByText("Which user outcome matters most?"));
await page.getByLabel("Depth").check();
await page.getByRole("button", { name: "Next question" }).click();
await expectVisible(page.getByText("Who should receive this first?"));
await expectVisible(page.getByLabel("Running plan").getByRole("heading", { name: "Adaptive planning workflow" }));
await page.getByRole("button", { name: "Validate plan" }).click();
await expectVisible(page.getByRole("button", { name: "Create Single Task" }));
await page.getByRole("button", { name: "Create Single Task" }).click();
for (let attempt = 0; attempt < 20 && await page.evaluate(() => document.body.dataset.createdTask) !== "FN-BROWSER"; attempt += 1) {
await page.waitForTimeout(50);
}
expect(await page.locator("body").getAttribute("data-created-task")).toBe("FN-BROWSER");
await page.close();
}, 30_000);
});

View File

@@ -151,6 +151,8 @@ describe("reactive Planning Mode question contract", () => {
}, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(next).toEqual(expect.objectContaining({ type: "question", data: expect.objectContaining({ id: "rollout" }) }));
expect(prompts.at(-1)).toContain("Ask about audit-log security before anything else.");
expect(prompts.at(-1)).toContain("exactly one new, high-impact question");
expect(prompts.at(-1)).toContain("only the user can validate it");
expect(events.filter((type) => type === "summary")).toHaveLength(2);
await validateSession(sessionId);

View File

@@ -341,4 +341,12 @@ describe("planning interview formatter Other answers", () => {
);
expect(formatInterviewQA([{ question: confirmQuestion, response }])).toContain("Comment: Need product input");
});
it("reasserts the infinite, high-impact next-question contract with every answer", () => {
const prompt = formatResponseForAgent(singleSelectQuestion, { strategy: "discovery" });
expect(prompt).toContain("exactly one new, high-impact question");
expect(prompt).toContain("does not repeat a prior question");
expect(prompt).toContain("only the user can validate it");
});
});

View File

@@ -222,6 +222,11 @@ async function ensureNtfyHelpersReady(): Promise<void> {
// ── Constants ───────────────────────────────────────────────────────────────
/*
FNXC:PlanningMode 2026-07-20-00:55:
Planning Mode is user-terminated: each answered turn must produce one consequential, novel question with alternatives and trade-offs.
The model may update the running plan but must never infer completion; only the visible Validate plan action can make a session terminal.
*/
/** Planning system prompt for the AI agent */
export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the fn task board system. First analyze the codebase and active board with the available read tools, fn_task_list, and fn_task_show. Turn a raw idea into an incrementally maintained plan.
@@ -2547,7 +2552,8 @@ function formatRefineRequestForAgent(summary: PlanningSummary): string {
return [
"The user clicked Refine Further on the planning summary.",
"Continue the planning interview from the existing context.",
"Either ask one focused follow-up question or return an updated completion summary if sufficient.",
"Ask exactly one focused, high-impact follow-up question with alternatives and pros/cons.",
"Do not return a completion response: only the user can validate a plan.",
"Current summary:",
JSON.stringify(summary),
].join("\n\n");
@@ -2910,7 +2916,13 @@ export function formatResponseForAgent(
break;
}
return comment.length > 0 ? `${formatted}\n\nAdditional context: ${comment}` : formatted;
const answerContext = comment.length > 0 ? `${formatted}\n\nAdditional context: ${comment}` : formatted;
/*
FNXC:PlanningMode 2026-07-20-00:55:
System prompts can be displaced by long tool/context turns. Repeat the per-answer contract at the invocation boundary
so every submitted answer steers the following high-impact question instead of inviting a model-generated completion.
*/
return `${answerContext}\n\nIncorporate this answer into the running plan, then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not complete or validate the plan; only the user can validate it.`;
}
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {