feat(FN-892): add planning mode multi-task creation with Break into Tasks

- Add generateSubtasksFromPlanning function to generate subtasks from planning session key deliverables
- Add POST /planning/start-breakdown and POST /planning/create-tasks API routes
- Add startPlanningBreakdown and createTasksFromPlanning frontend API functions
- Extend PlanningModeModal with Break into Tasks UI and subtask editing (drag-and-drop, dependency validation)
- Add comprehensive tests for generateSubtasksFromPlanning with edge cases
- Document planning mode multi-task creation feature in AGENTS.md
This commit is contained in:
gsxdsm
2026-04-04 14:25:34 -07:00
parent d2bfe56874
commit 78e95246d8
8 changed files with 931 additions and 11 deletions

View File

@@ -14,6 +14,7 @@ import {
SessionNotFoundError,
InvalidSessionStateError,
parseAgentResponse,
generateSubtasksFromPlanning,
} from "./planning.js";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -372,4 +373,157 @@ describe("planning module", () => {
expect(result.type).toBe("complete");
});
});
describe("generateSubtasksFromPlanning", () => {
/** Helper: create a session and complete it to get a summary */
async function createCompletedSession(
ip: string,
plan: string,
overrides?: Partial<PlanningSummary>
): Promise<string> {
const { sessionId } = await createSession(ip, plan);
// Complete the session by submitting 3 responses
await submitResponse(sessionId, { scope: "medium" });
await submitResponse(sessionId, { requirements: "Test requirements" });
await submitResponse(sessionId, { confirm: true });
return sessionId;
}
it("returns empty array if session not found", () => {
const result = generateSubtasksFromPlanning("non-existent-session-id");
expect(result).toEqual([]);
});
it("returns empty array if session has no summary (not complete)", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Incomplete session");
const result = generateSubtasksFromPlanning(sessionId);
expect(result).toEqual([]);
});
it("generates subtasks from keyDeliverables", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth system");
const result = generateSubtasksFromPlanning(sessionId);
// The stubbed session generates 3 key deliverables:
// "Implementation", "Tests", "Documentation"
expect(result.length).toBe(3);
// First subtask has no dependencies
expect(result[0]).toEqual({
id: "subtask-1",
title: "Implementation",
description: expect.any(String),
suggestedSize: "S",
dependsOn: [],
});
// Second subtask depends on first
expect(result[1]).toEqual({
id: "subtask-2",
title: "Tests",
description: expect.any(String),
suggestedSize: "M",
dependsOn: ["subtask-1"],
});
// Third subtask depends on second
expect(result[2]).toEqual({
id: "subtask-3",
title: "Documentation",
description: expect.any(String),
suggestedSize: "S",
dependsOn: ["subtask-2"],
});
});
it("generates fallback subtasks when keyDeliverables is empty", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Fallback test");
// Complete the session normally, then manually clear keyDeliverables
await submitResponse(sessionId, { scope: "small" });
await submitResponse(sessionId, { requirements: "test" });
await submitResponse(sessionId, { confirm: true });
// Get the session and manually clear keyDeliverables to test fallback
const session = getSession(sessionId);
expect(session).toBeDefined();
if (session?.summary) {
session.summary.keyDeliverables = [];
}
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBe(3);
expect(result[0]).toEqual({
id: "subtask-1",
title: "Define implementation approach",
description: expect.any(String),
suggestedSize: "S",
dependsOn: [],
});
expect(result[1]).toEqual({
id: "subtask-2",
title: "Implement core changes",
description: expect.any(String),
suggestedSize: "M",
dependsOn: ["subtask-1"],
});
expect(result[2]).toEqual({
id: "subtask-3",
title: "Verify and polish",
description: expect.any(String),
suggestedSize: "S",
dependsOn: ["subtask-2"],
});
});
it("assigns correct sizes based on deliverable position", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Multi-deliverable test");
// Complete the session
await submitResponse(sessionId, { scope: "large" });
await submitResponse(sessionId, { requirements: "many things" });
await submitResponse(sessionId, { confirm: true });
// Modify to have 5 deliverables for size variety
const session = getSession(sessionId);
if (session?.summary) {
session.summary.keyDeliverables = [
"Setup project structure",
"Build feature A",
"Build feature B",
"Build feature C",
"Integration tests",
];
}
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBe(5);
// First: S, Middle: M, Last: S
expect(result[0]?.suggestedSize).toBe("S");
expect(result[1]?.suggestedSize).toBe("M");
expect(result[2]?.suggestedSize).toBe("M");
expect(result[3]?.suggestedSize).toBe("M");
expect(result[4]?.suggestedSize).toBe("S");
});
it("uses sequential dependencies between subtasks", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Dependency test");
const result = generateSubtasksFromPlanning(sessionId);
// Each subtask depends on the previous one
for (let i = 1; i < result.length; i++) {
expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]);
}
});
});
});

View File

@@ -19,6 +19,7 @@ import type {
PlanningResponse,
TaskStore,
} from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
@@ -1164,6 +1165,61 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
return sessions.get(sessionId)?.summary;
}
/**
* Generate subtasks from a completed planning summary.
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
*
* @param sessionId - The planning session ID
* @returns Array of SubtaskItem with titles derived from keyDeliverables, or fallback
*/
export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
const session = sessions.get(sessionId);
if (!session) return [];
if (!session.summary) return [];
const { summary } = session;
// If key deliverables exist, create one subtask per deliverable
if (summary.keyDeliverables.length > 0) {
return summary.keyDeliverables.map((deliverable, index) => {
const id = `subtask-${index + 1}`;
const dependsOn = index > 0 ? [`subtask-${index}`] : [] as string[];
return {
id,
title: deliverable,
description: summary.description,
suggestedSize: index === 0 ? "S" as const : index === summary.keyDeliverables.length - 1 ? "S" as const : "M" as const,
dependsOn,
};
});
}
// Fallback: 3 subtasks
return [
{
id: "subtask-1",
title: "Define implementation approach",
description: summary.description,
suggestedSize: "S" as const,
dependsOn: [],
},
{
id: "subtask-2",
title: "Implement core changes",
description: summary.description,
suggestedSize: "M" as const,
dependsOn: ["subtask-1"],
},
{
id: "subtask-3",
title: "Verify and polish",
description: summary.description,
suggestedSize: "S" as const,
dependsOn: ["subtask-2"],
},
];
}
/**
* Cleanup a session (used after task creation).
*/

View File

@@ -1257,6 +1257,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
"POST /planning/respond",
"POST /planning/cancel",
"POST /planning/create-task",
"POST /planning/start-breakdown",
"POST /planning/create-tasks",
"GET /planning/:sessionId/stream",
];
console.debug("[planning:routes:registered]", planningRoutes);
@@ -5275,6 +5277,147 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/planning/start-breakdown
* Start subtask breakdown from a completed planning session.
* Body: { sessionId: string }
* Returns: { sessionId: string } — ID of the generated subtask breakdown
*/
router.post("/planning/start-breakdown", async (req, res) => {
try {
const { sessionId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
const { getSession, generateSubtasksFromPlanning } = await import("./planning.js");
const session = getSession(sessionId);
if (!session) {
res.status(404).json({ error: `Planning session ${sessionId} not found or expired` });
return;
}
if (!session.summary) {
res.status(400).json({ error: "Planning session is not complete" });
return;
}
const subtasks = generateSubtasksFromPlanning(sessionId);
if (subtasks.length === 0) {
res.status(400).json({ error: "Could not generate subtasks from planning session" });
return;
}
// Return a synthetic session ID (based on the planning session) and the generated subtasks
// We use the planning session ID directly as the breakdown session ID
res.json({ sessionId, subtasks });
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to start planning breakdown" });
}
});
/**
* POST /api/planning/create-tasks
* Create multiple tasks from a completed planning session (after optional editing).
* Body: { planningSessionId: string, subtasks: Array<{id, title, description, suggestedSize, dependsOn}> }
* Returns: { tasks: Task[] }
*/
router.post("/planning/create-tasks", async (req, res) => {
try {
const { planningSessionId, subtasks } = req.body as {
planningSessionId?: string;
subtasks?: Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
dependsOn: string[];
}>;
};
if (!planningSessionId || typeof planningSessionId !== "string") {
res.status(400).json({ error: "planningSessionId is required" });
return;
}
if (!Array.isArray(subtasks) || subtasks.length === 0) {
res.status(400).json({ error: "subtasks must be a non-empty array" });
return;
}
const { getSession, cleanupSession } = await import("./planning.js");
const session = getSession(planningSessionId);
if (!session) {
res.status(404).json({ error: `Planning session ${planningSessionId} not found or expired` });
return;
}
if (!session.summary) {
res.status(400).json({ error: "Planning session is not complete" });
return;
}
// Validate each subtask
for (const item of subtasks) {
if (!item || typeof item.id !== "string" || typeof item.title !== "string" || !item.title.trim()) {
res.status(400).json({ error: "Each subtask must include id and title" });
return;
}
}
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
const tempIdToTaskId = new Map<string, string>();
// Create tasks
for (const item of subtasks) {
const task = await store.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage",
dependencies: undefined,
});
tempIdToTaskId.set(item.id, task.id);
createdTasks.push(task);
if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") {
await store.updateTask(task.id, { size: item.suggestedSize });
}
}
// Resolve dependencies
for (let index = 0; index < subtasks.length; index++) {
const item = subtasks[index]!;
const created = createdTasks[index]!;
const resolvedDependencies = Array.isArray(item.dependsOn)
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
: [];
if (resolvedDependencies.length > 0) {
const updated = await store.updateTask(created.id, { dependencies: resolvedDependencies });
createdTasks[index] = updated;
}
await store.logEntry(
created.id,
"Created via Planning Mode (multi-task)",
`Source: ${session.initialPlan.slice(0, 200)}`
);
}
// Cleanup the planning session
cleanupSession(planningSessionId);
res.status(201).json({ tasks: createdTasks });
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to create tasks from planning" });
}
});
/**
* GET /api/planning/:sessionId/stream
* SSE endpoint for real-time planning session updates.