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
This commit is contained in:
gsxdsm
2026-06-25 08:39:52 -07:00
parent 04f0b31ee3
commit 575e211b04
7 changed files with 569 additions and 75 deletions

View File

@@ -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.

View File

@@ -113,10 +113,85 @@ function normalizeTaskPriority(priority?: TaskPriority): TaskPriority {
return DEFAULT_TASK_PRIORITY; return DEFAULT_TASK_PRIORITY;
} }
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
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 { function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary {
const raw = summary as PlanningSummary & Record<string, unknown>;
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 { return {
...summary, ...summary,
title,
description,
suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M",
priority: normalizeTaskPriority(summary.priority), 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<string, unknown>;
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) => { onQuestion: (question) => {
if (isStaleEvent()) return; if (isStaleEvent()) return;
const normalizedQuestion = normalizeQuestionOptions(question);
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
@@ -647,7 +723,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ setView({
type: "question", type: "question",
session: { sessionId, currentQuestion: question, summary: null }, session: { sessionId, currentQuestion: normalizedQuestion, summary: null },
}); });
setStreamingOutput(""); setStreamingOutput("");
@@ -663,6 +739,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}, },
onSummary: (summary) => { onSummary: (summary) => {
if (isStaleEvent()) return; if (isStaleEvent()) return;
const normalizedSummary = normalizePlanningSummary(summary);
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
@@ -679,10 +756,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ setView({
type: "summary", type: "summary",
session: { sessionId, currentQuestion: null, summary }, session: { sessionId, currentQuestion: null, summary: normalizedSummary },
summary, summary: normalizedSummary,
}); });
setEditedSummary(summary); setEditedSummary(normalizedSummary);
setStreamingOutput(""); setStreamingOutput("");
broadcastUpdate({ broadcastUpdate({
@@ -947,7 +1024,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "initial" }); setView({ type: "initial" });
} else if (session.status === "awaiting_input" && session.currentQuestion) { } else if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
const question = JSON.parse(session.currentQuestion); const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion));
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } }); setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
// Transfer persisted thinking into conversation history so it's // Transfer persisted thinking into conversation history so it's
// visible as expandable reasoning in the question view, instead of // visible as expandable reasoning in the question view, instead of
@@ -1712,7 +1789,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try { try {
const completedSessionId = view.session.sessionId; 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: { branchSelection: {
mode: branchMode, mode: branchMode,
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}), ...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
@@ -1749,12 +1827,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsStartingBreakdown(true); setIsStartingBreakdown(true);
try { try {
const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId); const normalizedSummary = editedSummary ? normalizePlanningSummary(editedSummary) : undefined;
const normalizedSubtasks = result.subtasks.map((subtask) => ({ const result = await startPlanningBreakdown(view.session.sessionId, normalizedSummary, projectId);
...subtask, const normalizedSubtasks = (Array.isArray(result.subtasks) ? result.subtasks : []).map(normalizeSubtaskItem);
priority: normalizeTaskPriority(subtask.priority),
dependsOn: [...subtask.dependsOn],
}));
setLockSessionId(result.sessionId); setLockSessionId(result.sessionId);
setView({ setView({
type: "breakdown", type: "breakdown",
@@ -1781,11 +1856,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const result = await createTasksFromPlanning( const result = await createTasksFromPlanning(
completedSessionId, completedSessionId,
buildCompactPlanningSubtaskDrafts( buildCompactPlanningSubtaskDrafts(
view.originalSubtasks, view.originalSubtasks.map(normalizeSubtaskItem),
view.subtasks.map((subtask) => ({ view.subtasks.map(normalizeSubtaskItem),
...subtask,
priority: normalizeTaskPriority(subtask.priority),
})),
), ),
projectId, projectId,
{ {
@@ -2294,7 +2366,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
subtasks={view.subtasks} subtasks={view.subtasks}
isLoading={isCreatingFromBreakdown} isLoading={isCreatingFromBreakdown}
onUpdateSubtasks={(newSubtasks) => onUpdateSubtasks={(newSubtasks) =>
setView({ ...view, subtasks: newSubtasks, dirty: true }) setView({ ...view, subtasks: newSubtasks.map(normalizeSubtaskItem), dirty: true })
} }
onCreateTasks={handleCreateTasksFromBreakdown} onCreateTasks={handleCreateTasksFromBreakdown}
onBack={() => { onBack={() => {
@@ -2351,8 +2423,10 @@ interface QuestionFormProps {
onBack?: () => void; onBack?: () => void;
} }
function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) { function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const question = normalizeQuestionOptions(rawQuestion);
const questionOptions = question.options ?? [];
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = 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" && (
<div className="planning-radio-group" role="radiogroup"> <div className="planning-radio-group" role="radiogroup">
{question.options.map((option) => ( {questionOptions.map((option) => (
<label key={option.id} className="planning-option planning-option--radio"> <label key={option.id} className="planning-option planning-option--radio">
<input <input
type="radio" type="radio"
@@ -2478,10 +2552,10 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
</div> </div>
)} )}
{question.type === "multi_select" && question.options && ( {question.type === "multi_select" && (
<div className="planning-checkbox-group"> <div className="planning-checkbox-group">
{question.options.map((option) => { {questionOptions.map((option) => {
const selected = (response[question.id] as string[]) || []; const selected = Array.isArray(response[question.id]) ? (response[question.id] as string[]) : [];
return ( return (
<label key={option.id} className="planning-option planning-option--checkbox"> <label key={option.id} className="planning-option planning-option--checkbox">
<input <input
@@ -2585,7 +2659,7 @@ interface SummaryViewProps {
} }
function SummaryView({ function SummaryView({
summary, summary: rawSummary,
historyEntries, historyEntries,
onSummaryChange, onSummaryChange,
tasks, tasks,
@@ -2602,6 +2676,7 @@ function SummaryView({
isStartingBreakdown, isStartingBreakdown,
}: SummaryViewProps) { }: SummaryViewProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const summary = normalizePlanningSummary(rawSummary);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [renderMarkdown, setRenderMarkdown] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(false);
const [selectedDependencies, setSelectedDependencies] = useState<string[]>( const [selectedDependencies, setSelectedDependencies] = useState<string[]>(
@@ -2791,9 +2866,13 @@ function SummaryView({
<div className="form-group"> <div className="form-group">
<label>{t("planning.keyDeliverables", "Key Deliverables")}</label> <label>{t("planning.keyDeliverables", "Key Deliverables")}</label>
<ul className="planning-deliverables"> <ul className="planning-deliverables">
{summary.keyDeliverables.map((item, i) => ( {summary.keyDeliverables.length > 0 ? (
<li key={i}>{item}</li> summary.keyDeliverables.map((item, i) => (
))} <li key={i}>{item}</li>
))
) : (
<li className="text-muted">{t("planning.noKeyDeliverables", "No key deliverables were provided.")}</li>
)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -226,6 +226,49 @@ describe("PlanningModeModal", () => {
}); });
describe("Planning flow", () => { describe("Planning flow", () => {
it.each(["desktop", "mobile"] as const)("FN-6977 renders malformed live summary without generic error on %s", async (viewportMode) => {
mockViewport(viewportMode);
mockStartPlanningStreaming.mockResolvedValueOnce({ sessionId: `session-fn-6977-live-${viewportMode}` });
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.({
title: "Live malformed summary",
description: "Live Planning Mode summary omitted deliverable arrays",
suggestedSize: "M",
});
}, 10);
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: "Plan from live malformed summary" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
expect(screen.getByDisplayValue("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();
});
it("starts planning and shows question view", async () => { it("starts planning and shows question view", async () => {
render( render(
<PlanningModeModal <PlanningModeModal
@@ -971,6 +1014,194 @@ describe("PlanningModeModal", () => {
}); });
}); });
it.each(["desktop", "mobile"] as const)("FN-6977 renders malformed persisted summary without generic error on %s", async (viewportMode) => {
mockViewport(viewportMode);
const malformedSummary = {
title: "Malformed summary without arrays",
description: "Recovered summary missing deliverable and dependency arrays",
suggestedSize: "M",
};
mockFetchAiSession.mockResolvedValueOnce({
id: `session-fn-6977-${viewportMode}`,
type: "planning",
status: "complete",
title: "Malformed summary without arrays",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed summary" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(malformedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId={`session-fn-6977-${viewportMode}`}
/>
);
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
expect(screen.getByDisplayValue("Recovered summary missing deliverable and dependency 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();
});
it("FN-6977 sends normalized empty arrays when creating from a malformed summary", async () => {
const malformedSummary = {
title: "Malformed summary create task",
description: "Recovered summary can still create a task",
suggestedSize: "M",
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-fn-6977-create",
type: "planning",
status: "complete",
title: "Malformed summary create task",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed summary and create" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(malformedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockCreateTaskFromPlanning.mockResolvedValueOnce({
id: "FN-6977",
title: "Created from malformed summary",
description: "",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as Task);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-fn-6977-create"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Single Task" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Create Single Task" }));
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
"session-fn-6977-create",
expect.objectContaining({
suggestedDependencies: [],
keyDeliverables: [],
}),
undefined,
expect.any(Object),
);
});
expect(screen.queryByText(/Something went wrong/i)).toBeNull();
});
it("FN-6977 starts breakdown from malformed summary and normalizes missing subtask dependsOn", async () => {
const malformedSummary = {
title: "Malformed summary breakdown",
description: "Recovered summary can still be broken down",
suggestedSize: "M",
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-fn-6977-breakdown",
type: "planning",
status: "complete",
title: "Malformed summary breakdown",
inputPayload: JSON.stringify({ initialPlan: "Recover malformed summary and break down" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(malformedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-fn-6977-breakdown",
subtasks: [
{
id: "subtask-1",
title: "Fallback implementation",
description: "Generated despite omitted deliverables",
suggestedSize: "M",
},
],
});
mockCreateTasksFromPlanning.mockResolvedValueOnce({ tasks: [] });
const onTasksCreated = vi.fn();
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={onTasksCreated}
tasks={mockTasks}
resumeSessionId="session-fn-6977-breakdown"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(mockStartPlanningBreakdown).toHaveBeenCalledWith(
"session-fn-6977-breakdown",
expect.objectContaining({ suggestedDependencies: [], keyDeliverables: [] }),
undefined,
);
expect(screen.getByDisplayValue("Fallback implementation")).toBeDefined();
});
expect(screen.getByText("First subtask cannot have dependencies.")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(mockCreateTasksFromPlanning).toHaveBeenCalledWith(
"session-fn-6977-breakdown",
[expect.objectContaining({ id: "subtask-1" })],
undefined,
expect.any(Object),
);
expect(onTasksCreated).toHaveBeenCalledWith([]);
});
expect(screen.queryByText(/Something went wrong/i)).toBeNull();
});
it("shows summary view when resuming a complete persisted session", async () => { it("shows summary view when resuming a complete persisted session", async () => {
const resumedSummary: PlanningSummary = { const resumedSummary: PlanningSummary = {
title: "Resume-ready planning output", title: "Resume-ready planning output",

View File

@@ -1396,6 +1396,70 @@ describe("Planning Mode Routes", () => {
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array); expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
}); });
it("FN-6977 normalizes omitted summary arrays from live AI completion", async () => {
const responses = [
JSON.stringify({
type: "question",
data: {
id: "q-one",
type: "text",
question: "What should be planned?",
},
}),
JSON.stringify({
type: "complete",
data: {
title: "Malformed AI summary",
description: "AI omitted array fields but completion is otherwise recoverable",
suggestedSize: "M",
suggestedDependencies: ["FN-100", "FN-100", 12],
},
}),
];
let callIndex = 0;
__setCreateFnAgent(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }, message: string) {
this.state.messages.push({ role: "user", content: message });
this.state.messages.push({ role: "assistant", content: responses[callIndex++] ?? responses[responses.length - 1] });
}),
dispose: vi.fn(),
},
}));
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Normalize malformed AI completion" }),
{ "Content-Type": "application/json" },
);
const sessionId = startRes.body.sessionId;
const finalRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { "q-one": "Finish" } }),
{ "Content-Type": "application/json" },
);
expect(finalRes.status).toBe(200);
expect(finalRes.body).toMatchObject({
type: "complete",
data: {
title: "Malformed AI summary",
suggestedDependencies: ["FN-100"],
keyDeliverables: [],
},
});
expect(planningModule.getSummary(sessionId)).toMatchObject({
suggestedDependencies: ["FN-100"],
keyDeliverables: [],
});
});
it("allows refine requests from completed sessions", async () => { it("allows refine requests from completed sessions", async () => {
const startRes = await REQUEST( const startRes = await REQUEST(
buildApp(), buildApp(),
@@ -1733,6 +1797,34 @@ describe("Planning Mode Routes", () => {
}), }),
); );
}); });
it("FN-6977 returns fallback subtasks when summary override omits deliverables", async () => {
const sessionId = await createCompletedPlanningSession("Break down malformed planning summary");
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-breakdown",
JSON.stringify({
sessionId,
summary: {
title: "Malformed breakdown summary",
description: "Deliverables were omitted by the AI or persisted session",
suggestedSize: "M",
},
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.subtasks).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "subtask-1", title: "Define implementation approach", dependsOn: [] }),
expect.objectContaining({ id: "subtask-2", title: "Implement core changes", dependsOn: ["subtask-1"] }),
expect.objectContaining({ id: "subtask-3", title: "Verify and polish", dependsOn: ["subtask-2"] }),
]),
);
});
}); });
describe("POST /planning/create-task", () => { describe("POST /planning/create-task", () => {
@@ -1971,7 +2063,37 @@ describe("Planning Mode Routes", () => {
expect(sessionRes.body).toMatchObject({ expect(sessionRes.body).toMatchObject({
id: sessionId, id: sessionId,
status: "complete", status: "complete",
result: storedSession?.result, });
expect(JSON.parse(sessionRes.body.result)).toMatchObject(JSON.parse(storedSession?.result ?? "{}"));
});
it("FN-6977 returns normalized persisted planning summary rows for resume", async () => {
const sessionId = "session-fn-6977-persisted-resume";
const mockAiSessionStore = {
get: vi.fn((id: string) => id === sessionId ? buildPlanningRow({
id: sessionId,
status: "complete",
title: "Malformed persisted summary",
result: JSON.stringify({
title: "Malformed persisted summary",
description: "Persisted row omitted keyDeliverables and included duplicate dependencies",
suggestedSize: "M",
suggestedDependencies: ["FN-100", "FN-100", "", 7],
}),
}) : null),
listAll: vi.fn(() => []),
listActive: vi.fn(() => []),
};
const appWithAiSessionStore = express();
appWithAiSessionStore.use(express.json());
appWithAiSessionStore.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
const sessionRes = await REQUEST(appWithAiSessionStore, "GET", `/api/ai-sessions/${sessionId}`);
expect(sessionRes.status).toBe(200);
expect(JSON.parse(sessionRes.body.result)).toMatchObject({
suggestedDependencies: ["FN-100"],
keyDeliverables: [],
}); });
}); });

View File

@@ -20,7 +20,7 @@ import type {
TaskStore, TaskStore,
NtfyNotificationEvent, NtfyNotificationEvent,
} from "@fusion/core"; } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, resolvePrompt, summarizeTitle, type PromptOverrideMap } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, resolvePrompt, summarizeTitle, type PromptOverrideMap } from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js"; import type { SubtaskItem } from "./subtask-breakdown.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
@@ -392,6 +392,62 @@ const activeGenerations = new Map<string, ActivePlanningGeneration>();
let _aiSessionStore: AiSessionStore | undefined; let _aiSessionStore: AiSessionStore | undefined;
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined; let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
function isTaskPriority(value: unknown): value is TaskPriority {
return typeof value === "string" && (TASK_PRIORITIES as readonly string[]).includes(value);
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
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:
AI responses and persisted planning rows are untrusted runtime data. Normalize omitted or malformed summary arrays to [] at the session boundary so #1743-style undefined `.map` crashes cannot reach live streams, resume, task creation, or breakdown generation.
*/
export function normalizePlanningSummaryPayload(
summaryInput: unknown,
fallback?: { title?: string; description?: string },
): PlanningSummary {
const summary = summaryInput && typeof summaryInput === "object" && !Array.isArray(summaryInput)
? summaryInput as Record<string, unknown>
: {};
const title = typeof summary.title === "string" && summary.title.trim().length > 0
? summary.title.trim()
: fallback?.title?.trim() || "Untitled planning task";
const description = typeof summary.description === "string" && summary.description.trim().length > 0
? summary.description.trim()
: fallback?.description?.trim() || title;
return {
title,
description,
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),
};
}
function safeParseJson<T>( function safeParseJson<T>(
text: string | null, text: string | null,
fallback: T, fallback: T,
@@ -540,10 +596,13 @@ function buildSessionFromRow(row: AiSessionRow): Session {
currentQuestion, currentQuestion,
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined, lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
summary: row.result summary: row.result
? (safeParseJson<PlanningSummary | null>(row.result, null, { ? normalizePlanningSummaryPayload(
throwOnError: true, safeParseJson<unknown | null>(row.result, null, {
fieldName: "result", throwOnError: true,
}) ?? undefined) fieldName: "result",
}),
{ title: row.title, description: row.title },
)
: undefined, : undefined,
thinkingOutput: row.thinkingOutput, thinkingOutput: row.thinkingOutput,
lastGeneratedThinking: row.thinkingOutput || "", lastGeneratedThinking: row.thinkingOutput || "",
@@ -1046,7 +1105,10 @@ async function getFirstQuestionFromAgent(
if (parsed.type === "complete") { if (parsed.type === "complete") {
// AI returned a summary instead of a question — return a minimal question // AI returned a summary instead of a question — return a minimal question
// so the caller can present the summary // so the caller can present the summary
const summary = parsed.data; const summary = normalizePlanningSummaryPayload(parsed.data, {
title: session.title || session.initialPlan,
description: session.initialPlan,
});
session.summary = summary; session.summary = summary;
persistSession(session, "complete"); persistSession(session, "complete");
return { return {
@@ -1888,14 +1950,18 @@ async function continueAgentConversation(session: Session, message: string): Pro
data: parsed.data, data: parsed.data,
}); });
} else if (parsed.type === "complete") { } else if (parsed.type === "complete") {
session.summary = parsed.data; const summary = normalizePlanningSummaryPayload(parsed.data, {
title: session.title || session.initialPlan,
description: session.initialPlan,
});
session.summary = summary;
session.currentQuestion = undefined; session.currentQuestion = undefined;
session.error = undefined; session.error = undefined;
session.updatedAt = new Date(); session.updatedAt = new Date();
persistSession(session, "complete"); persistSession(session, "complete");
planningStreamManager.broadcast(session.id, { planningStreamManager.broadcast(session.id, {
type: "summary", type: "summary",
data: parsed.data, data: summary,
}); });
planningStreamManager.broadcast(session.id, { type: "complete" }); planningStreamManager.broadcast(session.id, { type: "complete" });
} }
@@ -2577,7 +2643,11 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
if (!session) return []; if (!session) return [];
if (!session.summary) return []; if (!session.summary) return [];
const { summary } = session; const summary = normalizePlanningSummaryPayload(session.summary, {
title: session.title || session.initialPlan,
description: session.initialPlan,
});
session.summary = summary;
const qaSection = formatInterviewQA(session.history); const qaSection = formatInterviewQA(session.history);
// If key deliverables exist, create one subtask per deliverable plus a final verification subtask. // If key deliverables exist, create one subtask per deliverable plus a final verification subtask.

View File

@@ -38,7 +38,7 @@ import {
import type { ServerOptions } from "./server.js"; import type { ServerOptions } from "./server.js";
import { verifyWebhookSignature } from "./github-webhooks.js"; import { verifyWebhookSignature } from "./github-webhooks.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js"; import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js"; import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession, normalizePlanningSummaryPayload } from "./planning.js";
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js"; import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js"; import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js"; import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
@@ -4214,6 +4214,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!session) { if (!session) {
throw notFound("Session not found"); throw notFound("Session not found");
} }
if (session.type === "planning" && session.result) {
try {
res.json({
...session,
result: JSON.stringify(normalizePlanningSummaryPayload(JSON.parse(session.result), {
title: session.title,
description: session.title,
})),
});
return;
} catch {
// Preserve the existing invalid-result behavior for callers that can still recover client-side.
}
}
res.json(session); res.json(session);
}); });

View File

@@ -6,6 +6,7 @@ import {
type TaskPriority, type TaskPriority,
type TaskStore, type TaskStore,
} from "@fusion/core"; } from "@fusion/core";
import { normalizePlanningSummaryPayload } from "../planning.js";
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js"; import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
import type { AiSessionStore } from "../ai-session-store.js"; import type { AiSessionStore } from "../ai-session-store.js";
@@ -1009,21 +1010,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
throw badRequest("summary.description is required and must be a non-empty string"); throw badRequest("summary.description is required and must be a non-empty string");
} }
return { return normalizePlanningSummaryPayload(summary, {
title: summary.title.trim(), title: summary.title.trim(),
description: summary.description.trim(), description: summary.description.trim(),
suggestedSize: });
summary.suggestedSize === "S" || summary.suggestedSize === "M" || summary.suggestedSize === "L"
? summary.suggestedSize
: "M",
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
suggestedDependencies: Array.isArray(summary.suggestedDependencies)
? summary.suggestedDependencies.filter((dep): dep is string => typeof dep === "string" && dep.trim().length > 0)
: [],
keyDeliverables: Array.isArray(summary.keyDeliverables)
? summary.keyDeliverables.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: [],
};
}; };
const logPlanningCreateWarning = (message: string, error: unknown, metadata?: Record<string, unknown>): void => { const logPlanningCreateWarning = (message: string, error: unknown, metadata?: Record<string, unknown>): void => {
@@ -1108,29 +1098,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
keyDeliverables?: unknown; keyDeliverables?: unknown;
}; };
summary = { summary = normalizePlanningSummaryPayload(parsedSummary, {
title: title: persistedSession.title,
typeof parsedSummary.title === "string" && parsedSummary.title.trim().length > 0 description: persistedSession.title,
? parsedSummary.title });
: persistedSession.title,
description:
typeof parsedSummary.description === "string" && parsedSummary.description.trim().length > 0
? parsedSummary.description
: persistedSession.title,
suggestedSize:
parsedSummary.suggestedSize === "S" ||
parsedSummary.suggestedSize === "M" ||
parsedSummary.suggestedSize === "L"
? parsedSummary.suggestedSize
: "M",
priority: isTaskPriority(parsedSummary.priority) ? parsedSummary.priority : DEFAULT_TASK_PRIORITY,
suggestedDependencies: Array.isArray(parsedSummary.suggestedDependencies)
? parsedSummary.suggestedDependencies.filter((dep): dep is string => typeof dep === "string")
: [],
keyDeliverables: Array.isArray(parsedSummary.keyDeliverables)
? parsedSummary.keyDeliverables.filter((item): item is string => typeof item === "string")
: [],
};
} catch { } catch {
throw badRequest("Planning session result is invalid"); throw badRequest("Planning session result is invalid");
} }