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:
7
.changeset/fn-8420-planning-mode-user-validation.md
Normal file
7
.changeset/fn-8420-planning-mode-user-validation.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
5
packages/dashboard/app/planning-browser-e2e-fixture.html
Normal file
5
packages/dashboard/app/planning-browser-e2e-fixture.html
Normal 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>
|
||||
131
packages/dashboard/app/planning-browser-e2e-fixture.tsx
Normal file
131
packages/dashboard/app/planning-browser-e2e-fixture.tsx
Normal 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>,
|
||||
);
|
||||
115
packages/dashboard/src/__tests__/planning-browser-e2e.test.ts
Normal file
115
packages/dashboard/src/__tests__/planning-browser-e2e.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user