From 575e211b047aa5fe31dc2986be848882dcaa1ed9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 08:39:52 -0700 Subject: [PATCH] FN-6977: harden Planning Mode payload normalization Normalize Planning Mode runtime payloads so malformed AI summaries cannot crash UI or API flows. - Normalize summary, question option, subtask dependency, and priority fields at planning session boundaries. - Reuse summary normalization for persisted session reads, task creation, and planning breakdown generation. - Add regression coverage for malformed planning arrays and publish a patch changeset. Files changed: .changeset/fn-6977-planning-array-normalization.md | 7 + .../dashboard/app/components/PlanningModeModal.tsx | 135 +++++++++--- .../PlanningModeModal.planning-flow.test.tsx | 231 +++++++++++++++++++++ .../src/__tests__/routes-planning.test.ts | 124 ++++++++++- packages/dashboard/src/planning.ts | 88 +++++++- packages/dashboard/src/routes.ts | 16 +- .../src/routes/register-planning-subtask-routes.ts | 43 +--- 7 files changed, 569 insertions(+), 75 deletions(-) Fusion-Task-Id: FN-6977 Fusion-Task-Lineage: 538907b8-1037-4c12-9237-c73830621b73 --- .../fn-6977-planning-array-normalization.md | 7 + .../app/components/PlanningModeModal.tsx | 135 +++++++--- .../PlanningModeModal.planning-flow.test.tsx | 231 ++++++++++++++++++ .../src/__tests__/routes-planning.test.ts | 124 +++++++++- packages/dashboard/src/planning.ts | 88 ++++++- packages/dashboard/src/routes.ts | 16 +- .../register-planning-subtask-routes.ts | 43 +--- 7 files changed, 569 insertions(+), 75 deletions(-) create mode 100644 .changeset/fn-6977-planning-array-normalization.md diff --git a/.changeset/fn-6977-planning-array-normalization.md b/.changeset/fn-6977-planning-array-normalization.md new file mode 100644 index 0000000000..98868bd377 --- /dev/null +++ b/.changeset/fn-6977-planning-array-normalization.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent Planning Mode from crashing on malformed AI summary arrays. +category: fix +dev: Normalizes planning summaries, question options, subtasks, and dependency arrays at UI/API boundaries. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 49bcdb204a..d12db53a8b 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -113,10 +113,85 @@ function normalizeTaskPriority(priority?: TaskPriority): TaskPriority { return DEFAULT_TASK_PRIORITY; } +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const seen = new Set(); + const normalized: string[] = []; + for (const item of value) { + if (typeof item !== "string") { + continue; + } + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +/* +FNXC:PlanningNormalization 2026-06-25-00:00: +Planning Mode treats AI and persisted session payloads as untrusted runtime data. Missing summary arrays, question options, and subtask dependency arrays normalize before render/action state so #1743 cannot crash React with undefined `.map`. +*/ function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary { + const raw = summary as PlanningSummary & Record; + const title = typeof raw.title === "string" && raw.title.trim().length > 0 + ? raw.title.trim() + : "Untitled planning task"; + const description = typeof raw.description === "string" && raw.description.trim().length > 0 + ? raw.description.trim() + : title; return { ...summary, + title, + description, + 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), + }; +} + +function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion { + if (question.type !== "single_select" && question.type !== "multi_select") { + return question; + } + const options = Array.isArray(question.options) + ? question.options + .filter((option): option is { id: string; label: string; description?: string } => + Boolean( + option && + typeof option === "object" && + typeof option.id === "string" && + option.id.trim().length > 0 && + typeof option.label === "string" && + option.label.trim().length > 0 && + (option.description === undefined || typeof option.description === "string"), + ), + ) + .map((option) => ({ + ...option, + id: option.id.trim(), + label: option.label.trim(), + ...(option.description ? { description: option.description.trim() } : {}), + })) + : []; + return { ...question, options }; +} + +function normalizeSubtaskItem(subtask: SubtaskItem): SubtaskItem { + const raw = subtask as SubtaskItem & Record; + return { + ...subtask, + title: typeof raw.title === "string" ? raw.title : "", + description: typeof raw.description === "string" ? raw.description : "", + suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M", + priority: normalizeTaskPriority(subtask.priority), + dependsOn: normalizeStringArray(raw.dependsOn), }; } @@ -625,6 +700,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }, onQuestion: (question) => { if (isStaleEvent()) return; + const normalizedQuestion = normalizeQuestionOptions(question); setIsReconnecting(false); setIsRetrying(false); clearPlanningDescription(projectId); @@ -647,7 +723,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "question", - session: { sessionId, currentQuestion: question, summary: null }, + session: { sessionId, currentQuestion: normalizedQuestion, summary: null }, }); setStreamingOutput(""); @@ -663,6 +739,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }, onSummary: (summary) => { if (isStaleEvent()) return; + const normalizedSummary = normalizePlanningSummary(summary); setIsReconnecting(false); setIsRetrying(false); clearPlanningDescription(projectId); @@ -679,10 +756,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "summary", - session: { sessionId, currentQuestion: null, summary }, - summary, + session: { sessionId, currentQuestion: null, summary: normalizedSummary }, + summary: normalizedSummary, }); - setEditedSummary(summary); + setEditedSummary(normalizedSummary); setStreamingOutput(""); broadcastUpdate({ @@ -947,7 +1024,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "initial" }); } else if (session.status === "awaiting_input" && session.currentQuestion) { clearPlanningDescription(projectId); - const question = JSON.parse(session.currentQuestion); + const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion)); setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } }); // Transfer persisted thinking into conversation history so it's // visible as expandable reasoning in the question view, instead of @@ -1712,7 +1789,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat try { const completedSessionId = view.session.sessionId; - const task = await createTaskFromPlanning(completedSessionId, editedSummary ?? undefined, projectId, { + const normalizedSummary = editedSummary ? normalizePlanningSummary(editedSummary) : undefined; + const task = await createTaskFromPlanning(completedSessionId, normalizedSummary, projectId, { branchSelection: { mode: branchMode, ...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}), @@ -1749,12 +1827,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsStartingBreakdown(true); try { - const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId); - const normalizedSubtasks = result.subtasks.map((subtask) => ({ - ...subtask, - priority: normalizeTaskPriority(subtask.priority), - dependsOn: [...subtask.dependsOn], - })); + const normalizedSummary = editedSummary ? normalizePlanningSummary(editedSummary) : undefined; + const result = await startPlanningBreakdown(view.session.sessionId, normalizedSummary, projectId); + const normalizedSubtasks = (Array.isArray(result.subtasks) ? result.subtasks : []).map(normalizeSubtaskItem); setLockSessionId(result.sessionId); setView({ type: "breakdown", @@ -1781,11 +1856,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const result = await createTasksFromPlanning( completedSessionId, buildCompactPlanningSubtaskDrafts( - view.originalSubtasks, - view.subtasks.map((subtask) => ({ - ...subtask, - priority: normalizeTaskPriority(subtask.priority), - })), + view.originalSubtasks.map(normalizeSubtaskItem), + view.subtasks.map(normalizeSubtaskItem), ), projectId, { @@ -2294,7 +2366,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat subtasks={view.subtasks} isLoading={isCreatingFromBreakdown} onUpdateSubtasks={(newSubtasks) => - setView({ ...view, subtasks: newSubtasks, dirty: true }) + setView({ ...view, subtasks: newSubtasks.map(normalizeSubtaskItem), dirty: true }) } onCreateTasks={handleCreateTasksFromBreakdown} onBack={() => { @@ -2351,8 +2423,10 @@ interface QuestionFormProps { onBack?: () => void; } -function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) { +function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) { const { t } = useTranslation("app"); + const question = normalizeQuestionOptions(rawQuestion); + const questionOptions = question.options ?? []; const [response, setResponse] = useState({}); const [textValue, setTextValue] = useState(""); const [commentValue, setCommentValue] = useState(""); @@ -2456,9 +2530,9 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: /> )} - {question.type === "single_select" && question.options && ( + {question.type === "single_select" && (
- {question.options.map((option) => ( + {questionOptions.map((option) => (