fix(FN-2176): honor planning summary overrides for task generation

- Add optional summary payload support to planning create-task and start-breakdown API client calls
- Pass edited summary data from PlanningModeModal when creating a task or starting breakdown
- Parse and validate summary overrides in planning routes and prioritize override data when present
- Add and update dashboard tests to verify override handling and modal call expectations
This commit is contained in:
Fusion
2026-04-20 09:32:04 -07:00
committed by gsxdsm
parent a1f535008b
commit 74f2b25c1d
5 changed files with 235 additions and 46 deletions

View File

@@ -1779,23 +1779,28 @@ export function cancelPlanning(sessionId: string, projectId?: string, tabId?: st
}
/** Create a task from a completed planning session */
export function createTaskFromPlanning(sessionId: string, projectId?: string): Promise<Task> {
export function createTaskFromPlanning(
sessionId: string,
summary?: PlanningSummary,
projectId?: string,
): Promise<Task> {
return api<Task>(withProjectId("/planning/create-task", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }),
});
}
/** Start subtask breakdown from a completed planning session */
export function startPlanningBreakdown(
sessionId: string,
summary?: PlanningSummary,
projectId?: string,
): Promise<{ sessionId: string; subtasks: SubtaskItem[] }> {
return api<{ sessionId: string; subtasks: SubtaskItem[] }>(
withProjectId("/planning/start-breakdown", projectId),
{
method: "POST",
body: JSON.stringify({ sessionId }),
body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }),
},
);
}

View File

@@ -877,7 +877,7 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Create Task"));
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", undefined);
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", resumedSummary, undefined);
});
});
});
@@ -1415,7 +1415,7 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Create Task"));
await waitFor(() => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123", undefined);
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123", mockSummary, undefined);
expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask);
});
});
@@ -1475,7 +1475,7 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Break into Tasks"));
await waitFor(() => {
expect(mockStartPlanningBreakdown).toHaveBeenCalledWith("session-123", undefined);
expect(mockStartPlanningBreakdown).toHaveBeenCalledWith("session-123", mockSummary, undefined);
});
await waitFor(() => {

View File

@@ -686,14 +686,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "loading" });
try {
const task = await createTaskFromPlanning(view.session.sessionId, projectId);
const task = await createTaskFromPlanning(view.session.sessionId, editedSummary ?? undefined, projectId);
onTaskCreated(task);
handleCancel();
} catch (err: any) {
setError(err.message || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [view, onTaskCreated, handleCancel]);
}, [editedSummary, view, projectId, onTaskCreated, handleCancel]);
const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return;
@@ -702,7 +702,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "loading" });
try {
const result = await startPlanningBreakdown(view.session.sessionId, projectId);
const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId);
setLockSessionId(result.sessionId);
setView({
type: "breakdown",
@@ -714,7 +714,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(err.message || "Failed to start breakdown");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [view, projectId]);
}, [editedSummary, view, projectId]);
const handleCreateTasksFromBreakdown = useCallback(async () => {
if (view.type !== "breakdown") return;

View File

@@ -8611,6 +8611,68 @@ describe("Planning Mode Routes", () => {
});
});
describe("POST /planning/start-breakdown", () => {
it("uses summary override when generating subtasks", async () => {
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-breakdown",
JSON.stringify({
sessionId,
summary: {
title: "Edited auth implementation",
description: "Use OAuth providers and secure refresh tokens",
suggestedSize: "L",
suggestedDependencies: ["FN-321"],
keyDeliverables: ["OAuth integration"],
},
}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.sessionId).toBe(sessionId);
expect(res.body.subtasks).toHaveLength(1);
expect(res.body.subtasks[0]).toEqual(
expect.objectContaining({
title: "OAuth integration",
}),
);
expect(res.body.subtasks[0].description).toContain("Use OAuth providers and secure refresh tokens");
});
});
describe("POST /planning/create-task", () => {
it("creates a task from completed planning session", async () => {
// Setup mock store for task creation
@@ -8671,6 +8733,77 @@ describe("Planning Mode Routes", () => {
expect(store.createTask).toHaveBeenCalled();
});
it("uses summary override when provided", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-099",
description: "Edited task description",
column: "triage",
dependencies: ["FN-500"],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({
sessionId,
summary: {
title: "Edited auth task",
description: "Edited description from summary view",
suggestedSize: "S",
suggestedDependencies: ["FN-500"],
keyDeliverables: ["Login flow"],
},
}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: "Edited auth task",
description: "Edited description from summary view",
dependencies: ["FN-500"],
}),
);
expect(store.updateTask).toHaveBeenCalledWith("FN-099", { size: "S" });
});
it("creates a task from a persisted complete session when in-memory session is missing", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-043",

View File

@@ -17,7 +17,7 @@ import { tmpdir } from "node:os";
import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings, EnrichedChatSession } from "@fusion/core";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings, EnrichedChatSession, PlanningSummary } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend, readInsightsMemory, writeInsightsMemory, generateMemoryAudit, buildInsightExtractionPrompt, parseInsightExtractionResponse, processAndAuditInsightExtraction } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
@@ -8858,6 +8858,41 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
const parsePlanningSummaryOverride = (summaryInput: unknown): PlanningSummary | undefined => {
if (summaryInput === undefined) {
return undefined;
}
if (!summaryInput || typeof summaryInput !== "object" || Array.isArray(summaryInput)) {
throw badRequest("summary must be an object");
}
const summary = summaryInput as Partial<PlanningSummary>;
if (typeof summary.title !== "string" || summary.title.trim().length === 0) {
throw badRequest("summary.title is required and must be a non-empty string");
}
if (typeof summary.description !== "string" || summary.description.trim().length === 0) {
throw badRequest("summary.description is required and must be a non-empty string");
}
return {
title: summary.title.trim(),
description: summary.description.trim(),
suggestedSize:
summary.suggestedSize === "S" || summary.suggestedSize === "M" || summary.suggestedSize === "L"
? summary.suggestedSize
: "M",
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)
: [],
};
};
/**
* POST /api/planning/create-task
* Create a task from a completed planning session.
@@ -8866,17 +8901,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/planning/create-task", async (req, res) => {
try {
const { sessionId } = req.body;
const { sessionId, summary: summaryInput } = req.body as {
sessionId?: unknown;
summary?: unknown;
};
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
const { store: scopedStore } = await getProjectContext(req);
const { getSession, getSummary, cleanupSession } = await import("./planning.js");
const session = getSession(sessionId);
let summary = getSummary(sessionId);
let summary = summaryOverride ?? getSummary(sessionId);
let initialPlan = session?.initialPlan;
let usedPersistedFallback = false;
@@ -8898,39 +8938,41 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("Planning session result is not available");
}
try {
const parsedSummary = JSON.parse(persistedSession.result) as {
title?: unknown;
description?: unknown;
suggestedSize?: unknown;
suggestedDependencies?: unknown;
keyDeliverables?: unknown;
};
if (!summaryOverride) {
try {
const parsedSummary = JSON.parse(persistedSession.result) as {
title?: unknown;
description?: unknown;
suggestedSize?: unknown;
suggestedDependencies?: unknown;
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",
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 {
throw badRequest("Planning session result is invalid");
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",
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 {
throw badRequest("Planning session result is invalid");
}
}
try {
@@ -8993,12 +9035,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/planning/start-breakdown", async (req, res) => {
try {
const { sessionId } = req.body;
const { sessionId, summary: summaryInput } = req.body as {
sessionId?: unknown;
summary?: unknown;
};
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
const { getSession, generateSubtasksFromPlanning } = await import("./planning.js");
const session = getSession(sessionId);
@@ -9006,6 +9053,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw notFound(`Planning session ${sessionId} not found or expired`);
}
if (summaryOverride) {
session.summary = summaryOverride;
}
if (!session.summary) {
throw badRequest("Planning session is not complete");
}