From 606c320c5291b498a1ebfe95b38acc90cf0f3917 Mon Sep 17 00:00:00 2001
From: gsxdsm
Date: Mon, 20 Jul 2026 15:59:57 -0700
Subject: [PATCH] fix(FN-8442): make planning generation durable and detailed
Generate a reviewable initial plan before clarification, persist generation purpose across refreshes, and surface concrete changes and acceptance criteria with focused refinement choices.
---
.changeset/planning-initial-plan-refresh.md | 7 +
MOBILE.md | 2 +-
docs/dashboard-guide.md | 4 +-
.../src/__tests__/planning-plan-md.test.ts | 6 +-
packages/core/src/planning-plan-md.ts | 30 +++-
packages/core/src/types.ts | 6 +
.../app/components/PlanningModeModal.css | 14 ++
.../app/components/PlanningModeModal.tsx | 161 ++++++++++++++++--
.../PlanningModeModal.planning-flow.test.tsx | 83 +++++++--
.../planning-infinite-interview.test.ts | 81 ++++++---
.../src/__tests__/routes-planning.test.ts | 18 +-
packages/dashboard/src/planning.ts | 74 ++++++--
12 files changed, 400 insertions(+), 86 deletions(-)
create mode 100644 .changeset/planning-initial-plan-refresh.md
diff --git a/.changeset/planning-initial-plan-refresh.md b/.changeset/planning-initial-plan-refresh.md
new file mode 100644
index 0000000000..57bda954b1
--- /dev/null
+++ b/.changeset/planning-initial-plan-refresh.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Make Planning Mode generate a durable initial plan before asking optional refinement questions.
+category: fix
+dev: Persists generation purpose, adds detailed change/acceptance sections and suggested focuses, and deduplicates Other.
diff --git a/MOBILE.md b/MOBILE.md
index 01610d8360..df1c5761db 100644
--- a/MOBILE.md
+++ b/MOBILE.md
@@ -101,7 +101,7 @@ gesture delivery enabled across `cap sync` regenerations.
### Planning Mode
-Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Every viewport uses the same sequential surface: idea, one question, then plan review. Plan review provides an optional focus field followed by **Refine** and **Validate**; refine asks the next question and validate creates the task. There is no three-pane interview or Question/Running plan/Answered questions tab switcher. On mobile, opening Planning with saved sessions lands on the full-pane, scrollable saved-session list with **New session** as its footer. **Sessions** and the mobile back control always return to that list, including from plan review and create retry.
+Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Every viewport uses the same sequential surface: idea, generated initial plan, then optional refinement questions. Plan review offers model-suggested focus choices plus **Write your own focus**, followed by **Refine** and **Validate**; refine asks one question and validate creates the task. Generation remains resumable across refreshes, and answer turns visibly report **Updating plan…** before returning to review. There is no three-pane interview or Question/Running plan/Answered questions tab switcher. On mobile, opening Planning with saved sessions lands on the full-pane, scrollable saved-session list with **New session** as its footer. **Sessions** and the mobile back control always return to that list, including from plan review and create retry.
### Chat and Quick Chat mobile scroll/readability behavior
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 5259de8a23..90e052af6b 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -530,8 +530,8 @@ Use **New session** to restart planning with a different idea.
Planning Mode uses the selected workflow's `planning` seam—the same triage template used for newly added tasks—as its quality bar, then layers the user-controlled interview adapter on top. An explicitly configured `planning-system` prompt override replaces that full system prompt.
-
-Planning Mode is a single-surface sequence: enter an idea, answer one AI question, then review the evolving plan. Plan review shows the title, description, and deliverables plus an optional **Focus the next question** field. Choose **Refine** to ask another high-impact question; its optional focus can name a topic such as security or real-time behavior, or specify the next question directly. Answering that question returns to plan review rather than automatically starting another question.
+
+Planning Mode is a single-surface sequence: enter an idea, wait while Fusion generates a concrete initial plan, then review that evolving work product before deciding whether clarification is needed. Plan review shows the title, description, explicit **What to change** and **Acceptance criteria** sections, and deliverables, plus model-suggested **Focus the next question** choices and **Write your own focus**. Choose **Refine** to ask one high-impact question or **Validate** to accept the current plan. Answering a question shows **Updating plan…** and returns to plan review rather than automatically starting another question. The active generation purpose and running plan are persisted, so refreshing or navigating away during generation restores the correct progress state and eventual review.
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
diff --git a/packages/core/src/__tests__/planning-plan-md.test.ts b/packages/core/src/__tests__/planning-plan-md.test.ts
index de893be6a1..90aa20cafe 100644
--- a/packages/core/src/__tests__/planning-plan-md.test.ts
+++ b/packages/core/src/__tests__/planning-plan-md.test.ts
@@ -3,9 +3,11 @@ import { formatPlanningPlanMd, parsePlanningPlanMd } from "../planning-plan-md.j
describe("Planning Mode plan.md", () => {
it("round-trips the lean operator schema without priority", () => {
- const formatted = formatPlanningPlanMd({ title: "Ship plans", description: "Keep plans durable.", suggestedSize: "M", priority: "high", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
+ const formatted = formatPlanningPlanMd({ title: "Ship plans", description: "Keep plans durable.", proposedChanges: ["Persist the plan state"], acceptanceCriteria: ["Refresh restores the active plan"], suggestedSize: "M", priority: "high", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
expect(formatted).not.toContain("## Priority");
- expect(parsePlanningPlanMd(formatted)).toEqual({ title: "Ship plans", description: "Keep plans durable.", suggestedSize: "M", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
+ expect(formatted).toContain("## What to change\n- Persist the plan state");
+ expect(formatted).toContain("## Acceptance criteria\n- Refresh restores the active plan");
+ expect(parsePlanningPlanMd(formatted)).toEqual({ title: "Ship plans", description: "Keep plans durable.", proposedChanges: ["Persist the plan state"], acceptanceCriteria: ["Refresh restores the active plan"], suggestedSize: "M", suggestedDependencies: ["FN-1"], keyDeliverables: ["Format plan", "Store request"] });
});
it("round-trips empty dependencies", () => {
diff --git a/packages/core/src/planning-plan-md.ts b/packages/core/src/planning-plan-md.ts
index 11fa53fcf5..fd867c9d04 100644
--- a/packages/core/src/planning-plan-md.ts
+++ b/packages/core/src/planning-plan-md.ts
@@ -6,6 +6,11 @@ FN-8441 makes plan.md Planning Mode's lean, durable operator product. Triage alo
expands it into PROMPT.md; priority remains a task-row field and is never serialized here.
*/
export function formatPlanningPlanMd(summary: PlanningSummary): string {
+ const list = (items: string[] | undefined) => items && items.length > 0
+ ? items.map((item) => `- ${item}`).join("\n")
+ : "_None_";
+ const proposedChanges = list(summary.proposedChanges);
+ const acceptanceCriteria = list(summary.acceptanceCriteria);
const dependencies = summary.suggestedDependencies.length > 0
? summary.suggestedDependencies.map((dependency) => `- ${dependency}`).join("\n")
: "_None_";
@@ -13,7 +18,7 @@ export function formatPlanningPlanMd(summary: PlanningSummary): string {
? summary.keyDeliverables.map((deliverable) => `- ${deliverable}`).join("\n")
: "_None_";
- return `# ${summary.title}\n\n${summary.description}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
+ return `# ${summary.title}\n\n${summary.description}\n\n## What to change\n${proposedChanges}\n\n## Acceptance criteria\n${acceptanceCriteria}\n\n## Size\n${summary.suggestedSize}\n\n## Suggested dependencies\n${dependencies}\n\n## Key deliverables\n${deliverables}\n`;
}
/*
@@ -23,15 +28,32 @@ canonical section sequence emitted by formatPlanningPlanMd so user prose cannot
round trips or the fail-soft original-description body.
*/
export function parsePlanningPlanMd(text: string): Partial | null {
+ const list = (value: string) => value.trim() === "_None_"
+ ? []
+ : value.split("\n").map((line) => line.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
+ const expanded = text.match(
+ /^#\s+(.+)\n\n([\s\S]*)\n\n## What to change\n([\s\S]*?)\n\n## Acceptance criteria\n([\s\S]*?)\n\n## Size\n([SML])\n\n## Suggested dependencies\n([\s\S]*?)\n\n## Key deliverables\n([\s\S]*?)\n?$/,
+ );
+ if (expanded) {
+ const [, rawTitle, description, proposedChanges, acceptanceCriteria, rawSize, dependencies, deliverables] = expanded;
+ return {
+ title: rawTitle!.trim(),
+ description: description!.trim(),
+ proposedChanges: list(proposedChanges!),
+ acceptanceCriteria: list(acceptanceCriteria!),
+ suggestedSize: rawSize as PlanningSummary["suggestedSize"],
+ suggestedDependencies: list(dependencies!),
+ keyDeliverables: list(deliverables!),
+ };
+ }
+
+ // Backward compatibility for plan.md artifacts created before the detail sections existed.
const canonical = text.match(
/^#\s+(.+)\n\n([\s\S]*)\n\n## Size\n([SML])\n\n## Suggested dependencies\n([\s\S]*?)\n\n## Key deliverables\n([\s\S]*?)\n?$/,
);
if (!canonical) return null;
const [, rawTitle, description, rawSize, dependencies, deliverables] = canonical;
- const list = (value: string) => value.trim() === "_None_"
- ? []
- : value.split("\n").map((line) => line.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
return {
title: rawTitle!.trim(),
description: description!.trim(),
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 65d64162b9..b2ccbc1cf1 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -5663,10 +5663,16 @@ export interface PlanningQuestion {
export interface PlanningSummary {
title: string;
description: string;
+ /** Concrete product, code, or configuration changes proposed by the model. */
+ proposedChanges?: string[];
+ /** Observable pass/fail conditions for the implementation. */
+ acceptanceCriteria?: string[];
suggestedSize: "S" | "M" | "L";
priority?: TaskPriority;
suggestedDependencies: string[];
keyDeliverables: string[];
+ /** Model-suggested areas the operator can choose for the next refinement question. */
+ suggestedRefinements?: string[];
}
/** Response from planning endpoints - either a question or the final summary */
diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css
index f1f49c3065..88c435b54c 100644
--- a/packages/dashboard/app/components/PlanningModeModal.css
+++ b/packages/dashboard/app/components/PlanningModeModal.css
@@ -1113,6 +1113,20 @@ margins so paragraphs and lists do not create unintended gaps.
gap: var(--space-sm);
}
+.planning-refine-focus {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.planning-refine-focus legend {
+ margin-bottom: var(--space-sm);
+ font-weight: 500;
+}
+
.planning-confirm-answer {
display: flex;
flex-direction: column;
diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx
index e9f5f26bf8..80789d2fb6 100644
--- a/packages/dashboard/app/components/PlanningModeModal.tsx
+++ b/packages/dashboard/app/components/PlanningModeModal.tsx
@@ -113,6 +113,8 @@ type ViewState =
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" };
+type PlanningGenerationActivity = "initial_plan" | "plan_update" | "question";
+
/**
* FNXC:PlanningMode 2026-07-20-00:00:
* A persisted planning `result` is an evolving running plan, not proof that the interview ended.
@@ -191,10 +193,13 @@ function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary {
...summary,
title,
description,
+ proposedChanges: normalizeStringArray(raw.proposedChanges),
+ acceptanceCriteria: normalizeStringArray(raw.acceptanceCriteria),
suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M",
priority: normalizeTaskPriority(summary.priority),
suggestedDependencies: normalizeStringArray(raw.suggestedDependencies),
keyDeliverables: normalizeStringArray(raw.keyDeliverables),
+ suggestedRefinements: normalizeStringArray(raw.suggestedRefinements).slice(0, 3),
};
}
@@ -215,7 +220,10 @@ function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion
option.id.trim().length > 0 &&
typeof option.label === "string" &&
option.label.trim().length > 0 &&
- (option.description === undefined || typeof option.description === "string"),
+ (option.description === undefined || typeof option.description === "string") &&
+ option.isOther !== true &&
+ option.id !== "other" &&
+ option.id !== PLANNING_OTHER_OPTION_ID,
),
)
.map((option) => ({
@@ -376,6 +384,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [loadedSessionTitle, setLoadedSessionTitle] = useState(null);
const [isRefiningSummary, setIsRefiningSummary] = useState(false);
const [generationStartTime, setGenerationStartTime] = useState(null);
+ const [generationActivity, setGenerationActivity] = useState("initial_plan");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const textareaRef = useRef(null);
// Align long-form planning composers with FN-5146's 640px chat convention so
@@ -430,6 +439,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
draftSessionIdRef is only populated after createPlanningDraft resolves, so it cannot gate concurrent creates while a request is in flight. This synchronous sentinel flips true before the await and gates all subsequent debounce fires, collapsing the create path to exactly one call. Cleared on failure so a later keystroke can retry.
*/
const draftCreateInFlightRef = useRef(false);
+ const draftCreatePromiseRef = useRef | null>(null);
const draftDebounceRef = useRef | null>(null);
// Tracks resumeSessionId values the user has explicitly dismissed (via "New
// Session"). Without this, the resume effect re-fires on every callback
@@ -549,6 +559,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// FNXC:PlanningModeMobile 2026-07-20-10:30: Empty mobile state opens the composer because no saved destination exists; once sessions exist, every compact detail surface gets this single Back-to-list escape.
const canReturnToSessionList = isCompactInterview && mobileShowDetail && planningSessions.length > 0;
const [refineFocus, setRefineFocus] = useState("");
+ const [customRefineFocus, setCustomRefineFocus] = useState(null);
const { addToast } = useToast();
const { pushNav } = useNavigationHistoryContext();
@@ -726,7 +737,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const session = await fetchAiSession(sessionId);
if (cancelled || !session) return;
if (currentSessionIdRef.current !== sessionId) return;
- if (session.status === "awaiting_input" && session.currentQuestion) {
+ if (session.status === "awaiting_input" && !session.currentQuestion && session.result) {
+ // The initial plan and answer updates intentionally settle without a question.
+ // Recover that plan when its SSE summary event was missed instead of polling forever.
+ resetPlanningAutoRetryBudget();
+ const history = parseConversationHistory(session.conversationHistory);
+ const summary = normalizePlanningSummary(JSON.parse(session.result) as PlanningSummary);
+ conversationHistoryRef.current = history;
+ runningSummaryRef.current = summary;
+ setConversationHistory(history);
+ setResponseHistory(history
+ .map((entry) => entry.response)
+ .filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response))));
+ setRunningSummary(summary);
+ setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
+ setStreamingOutput("");
+ } else if (session.status === "awaiting_input" && session.currentQuestion) {
/*
FNXC:PlanningTurnReconciliation 2026-07-20-10:36:
Missed SSE recovery must hydrate the server's entire interview turn together. Keeping
@@ -1225,6 +1251,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget();
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
+ setGenerationActivity("initial_plan");
+ savePlanningDescription(startedPlan, projectId);
setView({ type: "loading" });
try {
@@ -1234,13 +1262,40 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
? { planningModelProvider, planningModelId, thinkingLevel: planningThinkingLevel || undefined }
: (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined);
- const draftSessionId = draftSessionIdRef.current;
+ let draftSessionId = draftSessionIdRef.current;
+ if (!draftSessionId) {
+ if (draftDebounceRef.current) {
+ clearTimeout(draftDebounceRef.current);
+ draftDebounceRef.current = null;
+ }
+ const draftPromise = draftCreatePromiseRef.current ?? createPlanningDraft(startedPlan, projectId, modelOverride);
+ draftCreatePromiseRef.current = draftPromise;
+ draftCreateInFlightRef.current = true;
+ const draft = await draftPromise;
+ draftSessionId = draft.sessionId;
+ draftSessionIdRef.current = draft.sessionId;
+ draftCreatePromiseRef.current = null;
+ setPlanningSessions((previous) => dedupeSessionsById([{
+ id: draft.sessionId,
+ type: "planning",
+ status: "draft",
+ title: draft.title,
+ preview: startedPlan.length > 80 ? `${startedPlan.slice(0, 79).trimEnd()}…` : startedPlan,
+ projectId: projectId ?? null,
+ updatedAt: new Date().toISOString(),
+ archived: false,
+ }, ...previous]));
+ setSelectedSessionId(draft.sessionId);
+ }
+ // Persist the durable handle before starting generation so a refresh during
+ // the start request can reopen the draft/generating row in either draft path.
+ savePlanningActiveSession(draftSessionId, projectId);
const { sessionId } = await startPlanningStreaming(
startedPlan,
projectId,
modelOverride,
{ clarificationEnabled: true, ...(workflowId ? { workflowId } : {}) },
- draftSessionId ?? undefined,
+ draftSessionId,
);
draftSessionIdRef.current = null;
currentSessionIdRef.current = sessionId;
@@ -1252,6 +1307,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
connectToPlanningStream(sessionId);
setResponseHistory([]);
} catch (err) {
+ draftCreatePromiseRef.current = null;
+ draftCreateInFlightRef.current = false;
setError(getErrorMessage(err) || t("planning.failedStartSession", "Failed to start planning session"));
setView({ type: "initial" });
currentSessionIdRef.current = null;
@@ -1372,6 +1429,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// An unavailable payload cannot provide a safe copy target.
}
setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : "");
+ if (inputPayload?.generationPurpose === "plan_update" || inputPayload?.generationPurpose === "question" || inputPayload?.generationPurpose === "initial_plan") {
+ setGenerationActivity(inputPayload.generationPurpose);
+ } else if (session.status === "generating") {
+ setGenerationActivity("initial_plan");
+ }
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
@@ -1659,6 +1721,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
draftSessionIdRef.current = null;
+ draftCreatePromiseRef.current = null;
+ draftCreateInFlightRef.current = false;
if (draftDebounceRef.current) {
clearTimeout(draftDebounceRef.current);
draftDebounceRef.current = null;
@@ -2055,6 +2119,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
conversationHistoryRef.current = optimisticHistory;
setConversationHistory(optimisticHistory);
resetPlanningAutoRetryBudget();
+ setGenerationActivity("plan_update");
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
liveGenerationSessionIdRef.current = sessionId;
@@ -2171,10 +2236,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleRefineFromPlan = useCallback(async () => {
if (view.type !== "plan_review") return;
setError(null);
+ setGenerationActivity("question");
setView({ type: "loading" });
try {
await respondToPlanning(view.session.sessionId, { refine: true, ...(refineFocus.trim() ? { focus: refineFocus.trim() } : {}) }, projectId);
setRefineFocus("");
+ setCustomRefineFocus(null);
} catch (err) {
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan"));
setView({ type: "plan_review", session: view.session, summary: view.summary });
@@ -2536,7 +2603,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{t("planning.initialHeading", "Transform your idea into a detailed task")}
- {t("planning.initialSubheading", "Describe what you want to build in plain language. The AI will ask clarifying questions and help you structure a well-defined task.")}
+ {t("planning.initialSubheading", "Describe what you want to build in plain language. The AI will generate an initial plan, then you can refine or validate it.")}
}
-
- setRefineFocus(event.target.value)} placeholder={t("planning.refineFocusPlaceholder", "For example: security, real-time updates, or a specific question")}/>
+
-
+
diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx
index 8f40a464a6..e410fd5a86 100644
--- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx
+++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx
@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { PlanningModeModal } from "../PlanningModeModal";
-import { mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
+import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
+
+const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile"));
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
-vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => "desktop", isMobileViewport: () => false, useViewportMode: () => "desktop" }));
+vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => mockViewportMode(), isMobileViewport: () => mockViewportMode() === "mobile", useViewportMode: () => mockViewportMode() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
vi.mock("../../api", () => {
@@ -14,32 +16,91 @@ vi.mock("../../api", () => {
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
- startPlanning: fn(), startPlanningStreaming: fn(), createPlanningDraft: fn(), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: () => [], acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
+ startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: () => [], acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
};
});
const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
function renderSession(session: Record) { return render(); }
+const summaryWithRefinements = {
+ ...mockSummary,
+ proposedChanges: ["Change the authentication API", "Add durable session recovery"],
+ acceptanceCriteria: ["Refresh preserves generation", "The plan is reviewable before questions"],
+ suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
+};
describe("PlanningModeModal sequential flow", () => {
- beforeEach(() => { vi.clearAllMocks(); mockFetchAiSessions.mockResolvedValue([]); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
+ beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
+ it("persists a draft before generation and immediately shows initial-plan progress", async () => {
+ render();
+ fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
+ fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
+ expect(screen.getByText("Generating initial plan…")).toBeInTheDocument();
+ await waitFor(() => expect(mockCreatePlanningDraft).toHaveBeenCalledWith("Build secure accounts", "project-1", undefined));
+ await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build secure accounts", "project-1", undefined, { clarificationEnabled: true }, "draft-1"));
+ expect(localStorage.getItem("kb:project-1:kb-planning-active-session")).toBe("draft-1");
+ });
it("renders plan review after an answered turn without retired interview panes", async () => {
- mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
+ mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ initialPlan: "Secure accounts" }) });
renderSession({});
expect(await screen.findByTestId("planning-plan-review")).toHaveTextContent("Build authentication system");
- expect(screen.getByTestId("planning-refine-focus")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument();
+ expect(screen.getByText("What to change")).toBeInTheDocument();
+ expect(screen.getByText("Change the authentication API")).toBeInTheDocument();
+ expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
+ expect(screen.getByText("Refresh preserves generation")).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Rollout strategy" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Failure recovery" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Refine" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Validate" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
expect(document.querySelector(".planning-running-plan")).toBeNull();
expect(document.querySelector(".planning-answered-history")).toBeNull();
});
- it("sends trimmed focus only when Refine requests the next question", async () => {
- mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: "{}" });
+ it("sends a model-suggested focus when Refine requests the next question", async () => {
+ mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
mockRespondToPlanning.mockResolvedValue({}); renderSession({});
- fireEvent.change(await screen.findByTestId("planning-refine-focus"), { target: { value: " security " } });
+ fireEvent.click(await screen.findByRole("radio", { name: "Security boundaries" }));
+ expect(screen.getByRole("button", { name: "Refine" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Refine" }));
- await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "security" }, "project-1"));
+ await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Security boundaries" }, "project-1"));
+ expect(screen.getByText("Generating next question…")).toBeInTheDocument();
+ });
+ it("restores the updating-plan progress state after refresh", async () => {
+ mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) });
+ renderSession({});
+ expect(await screen.findByText("Updating plan…")).toBeInTheDocument();
+ });
+ it("renders exactly one write-your-own choice for normalized select questions", async () => {
+ mockFetchAiSession.mockResolvedValue({
+ ...base,
+ status: "awaiting_input",
+ currentQuestion: JSON.stringify({
+ id: "q-1",
+ type: "single_select",
+ question: "What should come next?",
+ options: [
+ { id: "security", label: "Security" },
+ { id: "rollout", label: "Rollout" },
+ { id: "other", label: "Other (write your own)", isOther: true },
+ ],
+ }),
+ result: JSON.stringify(summaryWithRefinements),
+ inputPayload: "{}",
+ });
+ renderSession({});
+ expect(await screen.findByText("What should come next?")).toBeInTheDocument();
+ expect(screen.getAllByText("Other (write your own)")).toHaveLength(1);
+ });
+ it("keeps detailed plan review and refinement choices available on mobile", async () => {
+ mockViewportMode.mockReturnValue("mobile");
+ mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: "{}" });
+ renderSession({});
+ expect(await screen.findByText("What to change")).toBeInTheDocument();
+ expect(screen.getByText("Acceptance criteria")).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Security boundaries" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Write your own focus" })).toBeInTheDocument();
});
it("restores a validated unlinked session to create-only retry", async () => {
mockFetchAiSession.mockResolvedValue({ ...base, status: "complete", currentQuestion: null, result: JSON.stringify(mockSummary), inputPayload: JSON.stringify({ validated: true }) });
diff --git a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts
index a778ee932d..10e641053f 100644
--- a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts
+++ b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts
@@ -43,7 +43,10 @@ function completePayload(): string {
data: {
title: "Secure account recovery delivery",
description: "Build a reviewed recovery workflow with audit coverage.",
+ proposedChanges: ["Add recovery-token lifecycle handling", "Expose recovery audit events"],
+ acceptanceCriteria: ["Users can recover accounts securely", "Every recovery attempt is auditable"],
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
+ suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
},
});
}
@@ -108,6 +111,21 @@ describe("reactive Planning Mode question contract", () => {
]);
});
+ it("deduplicates a model-authored Other option before appending the canonical one", () => {
+ const question = normalizePlanningQuestion({
+ id: "security",
+ type: "single_select",
+ question: "What matters most?",
+ options: [
+ { id: "safe", label: "Safe defaults" },
+ { id: "fast", label: "Fast delivery" },
+ { id: "other", label: "Other (write your own)" },
+ ],
+ });
+
+ expect(question.options?.filter((option) => option.id === "other" || option.isOther)).toHaveLength(1);
+ });
+
it("upgrades legacy text questions so every question has alternatives and Other", () => {
const question = normalizePlanningQuestion({ type: "text", question: "What matters next?", options: [{ id: "bad" }] });
expect(question).toEqual(expect.objectContaining({ type: "single_select", question: "What matters next?" }));
@@ -122,7 +140,7 @@ describe("reactive Planning Mode question contract", () => {
Other steering, and explicit-only validation invariant rather than only testing normalization.
*/
it("delivers planning-clarification metadata that can reopen the exact session", async () => {
- installScriptedAgent([payload(FIRST_QUESTION)]);
+ installScriptedAgent([completePayload(), payload(FIRST_QUESTION)]);
let resolveDelivered: ((message: Record) => void) | undefined;
const delivered = new Promise>((resolve) => {
resolveDelivered = resolve;
@@ -142,7 +160,15 @@ describe("reactive Planning Mode question contract", () => {
{ clarificationEnabled: true, messageStore: messageStore as never },
);
+ const initialPlanReady = new Promise((resolve) => {
+ planningStreamManager.subscribe(sessionId, (event) => {
+ if (event.type === "summary") resolve();
+ });
+ });
planningStreamManager.consumeInitialTurn(sessionId)?.();
+ await initialPlanReady;
+ await new Promise((resolve) => setImmediate(resolve));
+ await submitResponse(sessionId, { refine: true, focus: "Security boundaries" }, "/tmp/project", undefined, MOCK_TASK_STORE);
const message = await delivered;
expect(message).toMatchObject({
@@ -156,7 +182,7 @@ describe("reactive Planning Mode question contract", () => {
});
});
- it("keeps the streaming agent turn non-terminal after complete, persists its running plan, and validates only on user action", async () => {
+ it("generates a durable initial plan before any question and validates only on user action", async () => {
const prompts = installScriptedAgent([
completePayload(),
payload(SECOND_QUESTION),
@@ -172,33 +198,31 @@ describe("reactive Planning Mode question contract", () => {
{ clarificationEnabled: true },
);
const events: string[] = [];
- const firstQuestion = new Promise((resolve) => {
+ const initialPlanReady = new Promise((resolve) => {
planningStreamManager.subscribe(sessionId, (event) => {
events.push(event.type);
- if (event.type === "question") resolve(event.data as typeof SECOND_QUESTION);
+ if (event.type === "summary") resolve();
});
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
- const fallbackQuestion = await firstQuestion;
+ await initialPlanReady;
- // The streamed processAgentTurn seam must coerce generic complete output into a question.
- expect(fallbackQuestion.id).not.toBe("complete");
expect((await getSession(sessionId))?.summary).toMatchObject({
title: "Secure account recovery delivery",
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
+ suggestedRefinements: ["Security boundaries", "Rollout strategy", "Failure recovery"],
});
- expect((await getSession(sessionId))?.validated).toBe(false);
-
- const next = await submitResponse(sessionId, {
- [fallbackQuestion.id]: "other",
- _other: "Ask about audit-log security before anything else.",
- }, "/tmp/project", undefined, MOCK_TASK_STORE);
- // FNXC:PlanningMode 2026-07-20-20:15: Answers update the plan only; the next
- // question is user-triggered by refine so a focus can steer it.
- expect(next).toEqual(expect.objectContaining({ type: "complete" }));
expect((await getSession(sessionId))?.currentQuestion).toBeUndefined();
- expect(prompts.at(-1)).toContain("Ask about audit-log security before anything else.");
- expect(events.filter((type) => type === "summary")).toHaveLength(2);
+ expect((await getSession(sessionId))?.validated).toBe(false);
+ expect(events).not.toContain("question");
+ expect(prompts[0]).toContain("initial implementation plan");
+
+ const refine = await submitResponse(sessionId, {
+ refine: true,
+ focus: "Security boundaries",
+ }, "/tmp/project", undefined, MOCK_TASK_STORE);
+ expect(refine).toEqual(expect.objectContaining({ type: "question" }));
+ expect(prompts.at(-1)).toContain("Security boundaries");
await validateSession(sessionId);
expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
@@ -262,7 +286,7 @@ describe("reactive Planning Mode question contract", () => {
expect(created.summary.description).not.toBe(created.firstQuestion.question);
});
- it("uses a model-authored initial plan on the streaming first turn before its question event", async () => {
+ it("uses a model-authored initial plan on the streaming first turn without exposing its question", async () => {
installScriptedAgent([payload({
...FIRST_QUESTION,
runningPlan: {
@@ -275,22 +299,22 @@ describe("reactive Planning Mode question contract", () => {
"127.0.0.15", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
);
const events: string[] = [];
- const firstQuestion = new Promise((resolve) => {
+ const initialPlanReady = new Promise((resolve) => {
planningStreamManager.subscribe(sessionId, (event) => {
events.push(event.type);
- if (event.type === "question") resolve();
+ if (event.type === "summary") resolve();
});
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
- await firstQuestion;
+ await initialPlanReady;
expect((await getSession(sessionId))?.summary).toMatchObject({
title: "Streaming account recovery plan",
description: "Stage a secure recovery flow with observability.",
keyDeliverables: ["Design recovery token lifecycle", "Test recovery telemetry"],
});
- expect(events.indexOf("summary")).toBeLessThan(events.indexOf("question"));
+ expect(events).not.toContain("question");
});
it("recovers a plan-shaped streaming first turn when the model omits runningPlan", async () => {
@@ -299,15 +323,15 @@ describe("reactive Planning Mode question contract", () => {
"127.0.0.16", "Build secure account recovery", "/tmp/project", MOCK_TASK_STORE,
);
const events: string[] = [];
- const firstQuestion = new Promise((resolve) => {
+ const initialPlanReady = new Promise((resolve) => {
planningStreamManager.subscribe(sessionId, (event) => {
events.push(event.type);
- if (event.type === "question") resolve();
+ if (event.type === "summary") resolve();
});
});
planningStreamManager.consumeInitialTurn(sessionId)?.();
- await firstQuestion;
+ await initialPlanReady;
const session = await getSession(sessionId);
expect(session?.summary).toMatchObject({
@@ -316,7 +340,7 @@ describe("reactive Planning Mode question contract", () => {
});
expect(session?.summary?.description).not.toBe(FIRST_QUESTION.question);
expect(session?.summary?.keyDeliverables).not.toEqual([FIRST_QUESTION.question]);
- expect(events.indexOf("summary")).toBeLessThan(events.indexOf("question"));
+ expect(events).not.toContain("question");
});
it("merges a partial model running-plan update with the prior work product", async () => {
@@ -346,8 +370,11 @@ describe("reactive Planning Mode question contract", () => {
description: "Deliver a secure recovery experience with a gradual rollout.",
suggestedSize: "L",
priority: "high",
+ proposedChanges: ["Change the affected workflow to support: Build secure account recovery"],
+ acceptanceCriteria: ["The requested outcome works end to end for: Build secure account recovery"],
suggestedDependencies: ["Identity service"],
keyDeliverables: ["Add recovery token flow", "Test recovery audit events"],
+ suggestedRefinements: ["Scope and user experience", "Technical approach and integration", "Validation and rollout"],
});
});
diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts
index c4e0591ee6..6db671abb7 100644
--- a/packages/dashboard/src/__tests__/routes-planning.test.ts
+++ b/packages/dashboard/src/__tests__/routes-planning.test.ts
@@ -790,11 +790,10 @@ describe("Planning Mode Routes", () => {
});
describe("POST /planning/start-streaming", () => {
- it("broadcasts a mandatory question instead of accepting a first-turn completion", async () => {
+ it("broadcasts a reviewable initial plan without an unsolicited question", async () => {
const messages: Array<{ role: string; content: string }> = [];
const responses = [
- JSON.stringify({ type: "complete", data: { title: "Too early", description: "A plan", keyDeliverables: [] } }),
- JSON.stringify({ type: "question", data: { id: "q-stream-required", type: "text", question: "What risk should the plan address?" } }),
+ JSON.stringify({ type: "complete", data: { title: "Reporting workflow plan", description: "A concrete reporting plan", proposedChanges: ["Update report generation"], acceptanceCriteria: ["Reports complete successfully"], keyDeliverables: ["Implement report generation"] } }),
];
let responseIndex = 0;
__setCreateFnAgent(async () => ({
@@ -819,10 +818,9 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(201);
planningStreamManager.consumeInitialTurn(res.body.sessionId)!();
await vi.waitFor(() => {
- const questions = planningStreamManager.getBufferedEvents(res.body.sessionId, 0)
- .filter((event) => event.event === "question");
- expect(questions).toHaveLength(1);
- expect(JSON.parse(questions[0]!.data)).toMatchObject({ id: expect.any(String), type: expect.any(String) });
+ const events = planningStreamManager.getBufferedEvents(res.body.sessionId, 0);
+ expect(events.filter((event) => event.event === "summary")).toHaveLength(1);
+ expect(events.filter((event) => event.event === "question")).toHaveLength(0);
});
expect(messages).toHaveLength(2);
expect(messages[0]?.content).toContain("Build a detailed reporting workflow");
@@ -1299,7 +1297,8 @@ describe("Planning Mode Routes", () => {
expect(promptCalls).toHaveLength(1);
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("live first-turn reasoning");
- expect(streamRes.body).toContain("event: question");
+ expect(streamRes.body).toContain("event: summary");
+ expect(streamRes.body).not.toContain("event: question");
} finally {
vi.useRealTimers();
}
@@ -1351,7 +1350,8 @@ describe("Planning Mode Routes", () => {
expect(promptCalls).toHaveLength(1);
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("live first-turn reasoning");
- expect(streamRes.body).toContain("event: question");
+ expect(streamRes.body).toContain("event: summary");
+ expect(streamRes.body).not.toContain("event: question");
} finally {
vi.useRealTimers();
}
diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts
index dd4a79132e..aeef3656bf 100644
--- a/packages/dashboard/src/planning.ts
+++ b/packages/dashboard/src/planning.ts
@@ -240,11 +240,11 @@ export const PLANNING_SYSTEM_PROMPT = `## Planning Mode interaction adapter
First analyze the codebase and active board with the available readonly tools, fn_task_list, and fn_task_show. Treat the workflow planning template above as the quality bar and PROMPT.md structure for the evolving plan, but do not write PROMPT.md or use write tools during this interview.
-Ask exactly one next, high-impact question only when the user explicitly requests a refine turn. After an answer, update the running plan and do not ask a question. Never decide that the interview is complete or emit a terminal/complete response. Only the user can validate the plan.
+Start by producing a concrete initial plan for review without asking a question. Ask exactly one next, high-impact question only when the user explicitly requests a refine turn. After an answer, update the running plan and do not ask a question. A JSON response with type "complete" means only that the current plan update is ready for review; it never validates or terminates the session. Only the user can validate the plan.
-For a refine turn respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}. For an answer-update turn respond only with {"type":"complete","data":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}.
+For a refine turn respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2","next focus 3"]}}}. For an initial-plan or answer-update turn respond only with {"type":"complete","data":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2","next focus 3"]}}.
-Every turn must include the running-plan fields: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes this lean plan as plan.md without priority; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
+Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and exactly three concise suggestedRefinements informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes the plan as plan.md without priority or suggestedRefinements; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
/*
FNXC:PlanningMode 2026-07-20-14:30:
@@ -303,6 +303,7 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
*/
export interface DraftInputPayload {
initialPlan?: string;
+ generationPurpose?: "initial_plan" | "plan_update" | "question";
clarificationEnabled?: boolean;
lastMailboxNotifiedQuestionKey?: string;
modelProvider?: string;
@@ -399,7 +400,7 @@ interface Session {
claimOwnerToken?: string;
claimStartedAt?: string;
/** Whether the current generation must end at plan review rather than a question. */
- generationPurpose?: "plan_update" | "question";
+ generationPurpose?: "initial_plan" | "plan_update" | "question";
/** Last terminal error for retry UX */
error?: string;
/** AI agent session for real-time interaction */
@@ -506,12 +507,15 @@ export function normalizePlanningSummaryPayload(
return {
title,
description,
+ proposedChanges: normalizeStringArray(summary.proposedChanges),
+ acceptanceCriteria: normalizeStringArray(summary.acceptanceCriteria),
suggestedSize: summary.suggestedSize === "S" || summary.suggestedSize === "M" || summary.suggestedSize === "L"
? summary.suggestedSize
: "M",
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
suggestedDependencies: normalizeStringArray(summary.suggestedDependencies),
keyDeliverables: normalizeStringArray(summary.keyDeliverables),
+ suggestedRefinements: normalizeStringArray(summary.suggestedRefinements).slice(0, 3),
};
}
@@ -607,6 +611,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
...(typeof session.clarificationEnabled === "boolean"
? { clarificationEnabled: session.clarificationEnabled }
: {}),
+ ...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}),
...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
}),
conversationHistory: JSON.stringify(session.history),
@@ -742,6 +747,11 @@ function buildSessionFromRow(row: AiSessionRow): Session {
clarificationEnabled: typeof payload.clarificationEnabled === "boolean"
? payload.clarificationEnabled
: undefined,
+ generationPurpose: payload.generationPurpose === "initial_plan"
+ || payload.generationPurpose === "plan_update"
+ || payload.generationPurpose === "question"
+ ? payload.generationPurpose
+ : undefined,
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
? payload.lastMailboxNotifiedQuestionKey
: undefined,
@@ -1578,7 +1588,8 @@ export async function startExistingSession(
session.ntfyConfig = runtimeOptions.ntfyConfig;
session.messageStore = runtimeOptions.messageStore;
}
- persistSession(session, "generating");
+ session.generationPurpose = "initial_plan";
+ await persistSession(session, "generating");
planningStreamManager.registerInitialTurn(sessionId, () => {
session.pluginRunner = pluginRunner;
initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, pluginRunner).catch((err) => {
@@ -1661,7 +1672,8 @@ export async function createSessionWithAgent(
};
sessions.set(sessionId, session);
- persistSession(session, "generating");
+ session.generationPurpose = "initial_plan";
+ await persistSession(session, "generating");
planningStreamManager.registerInitialTurn(sessionId, () => {
initializeAgent(
@@ -1741,7 +1753,7 @@ async function initializeAgent(
session.updatedAt = new Date();
});
- await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan));
+ await continueAgentConversation(session, formatInitialRunningPlanRequestForAgent(session.initialPlan));
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
@@ -2144,6 +2156,18 @@ export function formatInitialPlanRequestForAgent(initialPlan: string): string {
].join("\n\n");
}
+/** Streaming Planning Mode starts with a reviewable work product, never an unsolicited question. */
+export function formatInitialRunningPlanRequestForAgent(initialPlan: string): string {
+ return [
+ "Create a concrete initial implementation plan from this operator idea.",
+ "Inspect the relevant codebase and active-board context before drafting it. Make the description specific about the affected behavior and intended outcome. Provide concrete proposedChanges that name what behavior, component, interface, data, or configuration should change, and acceptanceCriteria stated as observable pass/fail outcomes. Make every key deliverable an actionable work item rather than generic planning advice.",
+ "Also propose exactly three short suggestedRefinements representing the highest-value unresolved areas the operator could explore next.",
+ "Return only type:\"complete\" JSON with title, description, proposedChanges, acceptanceCriteria, suggestedSize, priority, suggestedDependencies, keyDeliverables, and suggestedRefinements. Do not ask a question yet and do not validate the plan; the operator will review it and explicitly choose Refine or Validate.",
+ "Operator idea:",
+ initialPlan,
+ ].join("\n\n");
+}
+
function buildFallbackDeliverables(initialPlan: string): string[] {
const subject = initialPlan.trim() || "the requested work";
return [
@@ -2173,10 +2197,19 @@ function buildRunningSummary(
return normalizePlanningSummaryPayload({
title: previousSummary?.title || `Plan: ${subject.slice(0, 74)}`,
description,
+ proposedChanges: previousSummary?.proposedChanges?.length
+ ? previousSummary.proposedChanges
+ : [`Change the affected workflow to support: ${subject}`],
+ acceptanceCriteria: previousSummary?.acceptanceCriteria?.length
+ ? previousSummary.acceptanceCriteria
+ : [`The requested outcome works end to end for: ${subject}`],
suggestedSize: previousSummary?.suggestedSize ?? "M",
priority: previousSummary?.priority,
suggestedDependencies: previousSummary?.suggestedDependencies ?? [],
keyDeliverables: previousSummary?.keyDeliverables?.length ? previousSummary.keyDeliverables : buildFallbackDeliverables(subject),
+ suggestedRefinements: previousSummary?.suggestedRefinements?.length
+ ? previousSummary.suggestedRefinements
+ : ["Scope and user experience", "Technical approach and integration", "Validation and rollout"],
}, { title: `Plan: ${subject}`, description: initialDescription });
}
@@ -2238,7 +2271,11 @@ export function normalizePlanningQuestion(input: unknown, userInput = ""): Plann
const normalized: PlanningQuestion = { id: typeof source.id === "string" && source.id.trim() ? source.id : randomUUID(), type, question,
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}) };
const raw = Array.isArray(source.options) ? source.options : [];
- const alternatives = raw.filter((item): item is Record => Boolean(item) && typeof item === "object" && !(item as Record).isOther)
+ const alternatives = raw.filter((item): item is Record => {
+ if (!item || typeof item !== "object") return false;
+ const option = item as Record;
+ return option.isOther !== true && option.id !== "other" && option.id !== "__other__";
+ })
.slice(0, 2).map((item, index) => ({
id: typeof item.id === "string" && item.id.trim() ? item.id : `option-${index + 1}`,
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : fallback.option(index + 1),
@@ -2441,7 +2478,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
- const planUpdate = session.generationPurpose === "plan_update";
+ const planUpdate = session.generationPurpose === "plan_update" || session.generationPurpose === "initial_plan";
session.generationPurpose = undefined;
/*
FNXC:PlanningMode 2026-07-20-15:45:
@@ -2450,7 +2487,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
triggered by the plan-review Refine action, optionally with operator focus text.
*/
session.currentQuestion = planUpdate ? undefined : coerceQuestionResponse(parsed, session);
- persistSession(session, "awaiting_input");
+ await persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
if (session.currentQuestion) {
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
@@ -2806,11 +2843,11 @@ export async function submitResponse(
throw new InvalidSessionStateError("No active question in session");
}
+ session.generationPurpose = "question";
session.error = undefined;
- persistSession(session, "generating");
+ await persistSession(session, "generating");
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
- session.generationPurpose = "question";
const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined;
const refineMessage = formatRefineRequestForAgent(session.summary, focus);
await continueAgentConversation(session, refineMessage);
@@ -2851,7 +2888,7 @@ export async function submitResponse(
*/
session.currentQuestion = undefined;
session.generationPurpose = "plan_update";
- persistSession(session, "generating");
+ await persistSession(session, "generating");
if (!session.agent) {
// An edited older answer must be replayed in its original position with every
// later answer retained; only a newly appended answer is sent after replay.
@@ -2934,11 +2971,12 @@ export async function retrySession(
*/
session.currentQuestion = undefined;
session.updatedAt = new Date();
- persistSession(session, "generating");
+ session.generationPurpose = session.history.length === 0 ? "initial_plan" : "plan_update";
+ await persistSession(session, "generating");
if (session.history.length === 0) {
await ensureSessionAgent(session, rootDir, [], promptOverrides, store);
- await continueAgentConversation(session, formatInitialPlanRequestForAgent(session.initialPlan));
+ await continueAgentConversation(session, formatInitialRunningPlanRequestForAgent(session.initialPlan));
return;
}
@@ -2946,10 +2984,10 @@ export async function retrySession(
const lastEntry = session.history[session.history.length - 1];
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides, store);
- const replayMessage = formatResponseForAgent(
+ const replayMessage = `${formatResponseForAgent(
lastEntry.question,
coerceResponseRecord(lastEntry.question, lastEntry.response),
- );
+ )}\n\nUpdate the running plan only. Do not ask a next question; the operator may explicitly refine after reviewing the plan.`;
await continueAgentConversation(session, replayMessage);
}
@@ -3108,7 +3146,7 @@ export function formatResponseForAgent(
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\nUpdate only the lean runningPlan fields (title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables) informed by this answer; never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. 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.`;
+ return `${answerContext}\n\nUpdate only the runningPlan fields (title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and three suggestedRefinements) informed by this answer; never list interview questions as deliverables or PROMPT.md sections such as Mission, Steps, File Scope, Review Level, Completion Criteria, or Do NOT. Then ask exactly one new, high-impact question that does not repeat a prior question. Offer alternatives with pros and cons. Do not validate the plan; only the user can validate it.`;
}
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record {