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:
7
.changeset/fn-6977-planning-array-normalization.md
Normal file
7
.changeset/fn-6977-planning-array-normalization.md
Normal 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.
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
...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<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) => {
|
||||
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<QuestionResponse>({});
|
||||
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" && (
|
||||
<div className="planning-radio-group" role="radiogroup">
|
||||
{question.options.map((option) => (
|
||||
{questionOptions.map((option) => (
|
||||
<label key={option.id} className="planning-option planning-option--radio">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -2478,10 +2552,10 @@ function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === "multi_select" && question.options && (
|
||||
{question.type === "multi_select" && (
|
||||
<div className="planning-checkbox-group">
|
||||
{question.options.map((option) => {
|
||||
const selected = (response[question.id] as string[]) || [];
|
||||
{questionOptions.map((option) => {
|
||||
const selected = Array.isArray(response[question.id]) ? (response[question.id] as string[]) : [];
|
||||
return (
|
||||
<label key={option.id} className="planning-option planning-option--checkbox">
|
||||
<input
|
||||
@@ -2585,7 +2659,7 @@ interface SummaryViewProps {
|
||||
}
|
||||
|
||||
function SummaryView({
|
||||
summary,
|
||||
summary: rawSummary,
|
||||
historyEntries,
|
||||
onSummaryChange,
|
||||
tasks,
|
||||
@@ -2602,6 +2676,7 @@ function SummaryView({
|
||||
isStartingBreakdown,
|
||||
}: SummaryViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const summary = normalizePlanningSummary(rawSummary);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
||||
const [selectedDependencies, setSelectedDependencies] = useState<string[]>(
|
||||
@@ -2791,9 +2866,13 @@ function SummaryView({
|
||||
<div className="form-group">
|
||||
<label>{t("planning.keyDeliverables", "Key Deliverables")}</label>
|
||||
<ul className="planning-deliverables">
|
||||
{summary.keyDeliverables.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
{summary.keyDeliverables.length > 0 ? (
|
||||
summary.keyDeliverables.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-muted">{t("planning.noKeyDeliverables", "No key deliverables were provided.")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,6 +226,49 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
render(
|
||||
<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 () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-ready planning output",
|
||||
|
||||
@@ -1396,6 +1396,70 @@ describe("Planning Mode Routes", () => {
|
||||
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 () => {
|
||||
const startRes = await REQUEST(
|
||||
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", () => {
|
||||
@@ -1971,7 +2063,37 @@ describe("Planning Mode Routes", () => {
|
||||
expect(sessionRes.body).toMatchObject({
|
||||
id: sessionId,
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
TaskStore,
|
||||
NtfyNotificationEvent,
|
||||
} 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 { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
@@ -392,6 +392,62 @@ const activeGenerations = new Map<string, ActivePlanningGeneration>();
|
||||
let _aiSessionStore: AiSessionStore | 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>(
|
||||
text: string | null,
|
||||
fallback: T,
|
||||
@@ -540,10 +596,13 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
currentQuestion,
|
||||
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
|
||||
summary: row.result
|
||||
? (safeParseJson<PlanningSummary | null>(row.result, null, {
|
||||
throwOnError: true,
|
||||
fieldName: "result",
|
||||
}) ?? undefined)
|
||||
? normalizePlanningSummaryPayload(
|
||||
safeParseJson<unknown | null>(row.result, null, {
|
||||
throwOnError: true,
|
||||
fieldName: "result",
|
||||
}),
|
||||
{ title: row.title, description: row.title },
|
||||
)
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
@@ -1046,7 +1105,10 @@ async function getFirstQuestionFromAgent(
|
||||
if (parsed.type === "complete") {
|
||||
// AI returned a summary instead of a question — return a minimal question
|
||||
// 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;
|
||||
persistSession(session, "complete");
|
||||
return {
|
||||
@@ -1888,14 +1950,18 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
data: parsed.data,
|
||||
});
|
||||
} 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.error = undefined;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "complete");
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "summary",
|
||||
data: parsed.data,
|
||||
data: summary,
|
||||
});
|
||||
planningStreamManager.broadcast(session.id, { type: "complete" });
|
||||
}
|
||||
@@ -2577,7 +2643,11 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
||||
if (!session) 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);
|
||||
|
||||
// If key deliverables exist, create one subtask per deliverable plus a final verification subtask.
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { verifyWebhookSignature } from "./github-webhooks.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 { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
||||
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
|
||||
@@ -4214,6 +4214,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (!session) {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type TaskPriority,
|
||||
type TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { normalizePlanningSummaryPayload } from "../planning.js";
|
||||
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
|
||||
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.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");
|
||||
}
|
||||
|
||||
return {
|
||||
return normalizePlanningSummaryPayload(summary, {
|
||||
title: summary.title.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 => {
|
||||
@@ -1108,29 +1098,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
keyDeliverables?: unknown;
|
||||
};
|
||||
|
||||
summary = {
|
||||
title:
|
||||
typeof parsedSummary.title === "string" && parsedSummary.title.trim().length > 0
|
||||
? 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")
|
||||
: [],
|
||||
};
|
||||
summary = normalizePlanningSummaryPayload(parsedSummary, {
|
||||
title: persistedSession.title,
|
||||
description: persistedSession.title,
|
||||
});
|
||||
} catch {
|
||||
throw badRequest("Planning session result is invalid");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user